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

Delayed sealing of `riskWindowStart` can cause late stakers to receive bonus weight inconsistent with actual active-risk exposure

Author Revealed upon completion

Root + Impact

Description

The pool distributes bonus using a k=2 time-weighted formula, where earlier capital is intended to receive more weight than later capital during the active-risk period.

However, riskWindowStart is not set when the agreement actually enters an active-risk state. It is only set when the pool first observes that state through a later interaction. Because staking is still allowed during UNDER_ATTACK, a late entrant can be the transaction that seals riskWindowStart.

This means bonus weighting can be based on the observation time of the active-risk state instead of the actual duration that earlier stakers were exposed to that state. As a result, late entrants can receive more bonus weight than intended, while earlier stakers can receive less.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
_assertDepositsAllowed(_observePoolState());
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
...
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
}

The issue is that _markRiskWindowStart() uses the timestamp of the first later observation, while stake() is still allowed in that same active-risk state.

Risk

Likelihood:

  • The issue occurs whenever the registry is already in an active-risk state and the pool has not yet observed that transition.

  • It remains reachable because stake() is intentionally allowed during UNDER_ATTACK, so a late entrant can be the first observer.

Impact:

  • Bonus distribution can become misaligned with actual active-risk exposure.

  • Earlier stakers can lose bonus weight they should have earned, while later entrants can receive more weight than intended.

Proof of Concept

This issue is reachable when the registry has already been in UNDER_ATTACK for some time, but no one has interacted with the pool since that transition. In that situation, the first later interaction determines riskWindowStart. If that first observer is a late staker, the contract starts the observed risk window at the late staker’s entry time rather than at the beginning of the real active-risk period.

function testSubmission_delayedRiskWindowStartLetsLateStakerShareAsIfEarly() external {
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(vm.getBlockTimestamp() + 5 days);
assertEq(pool.riskWindowStart(), 0, "risk window stayed unsealed during early attack time");
_stake(bob, 100 * ONE);
uint256 sealedStart = pool.riskWindowStart();
assertEq(sealedStart, vm.getBlockTimestamp(), "late staker seals the window on entry");
vm.warp(vm.getBlockTimestamp() + 1 days);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 alicePayout = token.balanceOf(alice) - aliceBefore;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobPayout = token.balanceOf(bob) - bobBefore;
assertEq(alicePayout, 150 * ONE, "early staker gets an equal split of the bonus");
assertEq(bobPayout, 150 * ONE, "late staker gets the same split despite shorter exposure");
}
  1. Alice stakes before the agreement becomes UNDER_ATTACK.

  2. The agreement enters UNDER_ATTACK.

  3. The pool remains untouched during that active-risk period, so riskWindowStart stays 0.

  4. After meaningful time has already passed in UNDER_ATTACK, Bob stakes as the first later pool interaction.

  5. Bob’s stake is the call that observes UNDER_ATTACK, so riskWindowStart is set to Bob’s late timestamp.

  6. The pool later resolves SURVIVED with a nonzero bonus.

  7. Alice and Bob claim, and both receive the same bonus split.

Result:
Alice’s earlier time exposed to active risk is not reflected in the observed bonus weighting. The late entrant is treated as if their at-risk window began at the same effective time as the earlier staker.

Recommended Mitigation

The fix is to prevent fresh stake from entering in the same transaction that first seals riskWindowStart. This ensures that once the pool first observes active risk, subsequent bonus weighting cannot immediately include a new entrant whose capital was not present during the already-elapsed portion of that risk period.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
+ uint32 priorRiskWindowStart = riskWindowStart;
_assertDepositsAllowed(_observePoolState());
+ if (priorRiskWindowStart == 0 && riskWindowStart != 0) revert StakingClosed();
if (!expiryLocked) {
expiryLocked = true;
}
...
}

Support

FAQs

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

Give us feedback!