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

Late first observation of `UNDER_ATTACK` lets a last-minute staker bypass k=2 bonus weighting and capture the bonus pot from long-term stakers

Author Revealed upon completion

Description

  • DESIGN.md section 3 says deposits during UNDER_ATTACK are acceptable because their bonus share is crushed to near-zero by the k=2 formula: their entry time is floored at riskWindowStart ≈ now, so (T - entry)^2 is tiny. The protocol also tests this in testQuadraticCrushesInsiderLastMinuteStaker, where a 50,000-token late insider should receive less than aliceBonus / 1000.

  • riskWindowStart is not set when the registry actually enters an active-risk state. It is set lazily when the pool first observes that state. When the pool is quiet while the registry is already UNDER_ATTACK, a late staker can be the first observer through their own stake() call. That call seals riskWindowStart at the late staker's entry time, and existing stakers are clamped to the same timestamp. The k=2 time weighting no longer differentiates long-term risk from last-minute risk; the bonus split degenerates to amount-weighting.

// stake(): a first-observer deposit is allowed to enter while it also observes active risk
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (block.timestamp >= expiry) revert StakingClosed();
@> _assertDepositsAllowed(_observePoolState());
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
@> if (start != 0 && newEntry < start) newEntry = start;
...
}
// _updateRiskWindowsFromState(): the first observation seals riskWindowStart
function _updateRiskWindowsFromState(IAttackRegistry.ContractState state) internal {
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) {
@> _markRiskWindowStart();
}
...
}
// _markRiskWindowStart(): the window is anchored to observation time
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
@> riskWindowStart = uint32(t);
@> sumStakeTime = totalEligibleStake * t;
@> sumStakeTimeSq = totalEligibleStake * t * t;
}
  • This contradicts the stated safety property for UNDER_ATTACK deposits. DESIGN.md section 7 documents the first-observation mechanic and frames the timing as a public race that only redistributes bonus among stakers. That explains the mechanism, but it does not reconcile the result with section 3 and testQuadraticCrushesInsiderLastMinuteStaker, which promise that a late depositor is crushed. A documented mechanism does not make the broken late-entry penalty the intended outcome.

Risk

Likelihood:

  • This occurs when the registry enters UNDER_ATTACK and no one calls pokeRiskWindow() before a later staker calls stake().

  • pokeRiskWindow() is permissionless but unrewarded, so the pool can remain unsealed until the next user interaction. Every pool has a fresh first-observer window.

Impact:

  • The bonus pot is redistributed away from long-term stakers and toward late capital. With the protocol's own test parameters, Alice stakes 100 tokens for 21 days of risk, while a 50,000-token insider stakes 10 minutes before resolution. Instead of being crushed, the insider receives about 99.8% of the bonus pot.

  • With equal stake sizes, a 1-day and 30-day commitment split the bonus 50/50. Principal is not lost, but the pool's intended reward mechanism is defeated.

Proof of Concept

Attack path:

  1. Alice stakes 100 tokens.

  2. The registry enters UNDER_ATTACK.

  3. No one calls pokeRiskWindow(), so riskWindowStart remains 0.

  4. 21 days later, Eve stakes 50,000 tokens and becomes the first observer.

  5. Eve's stake call seals riskWindowStart at her own late entry time.

  6. The pool later resolves SURVIVED.

  7. Alice and Eve claim; Eve receives almost the entire bonus pot.

Add this test to a fresh PoC file and run:

forge test --match-test test_lateInsider_capturesBonus_whenRiskWindowUnpoked -vv
function test_lateInsider_capturesBonus_whenRiskWindowUnpoked() external {
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(vm.getBlockTimestamp() + 21 days);
// Eve is both the late staker and the first observer of UNDER_ATTACK.
_stake(eve, 50_000 * ONE);
vm.warp(vm.getBlockTimestamp() + 10 minutes);
_contributeBonus(dave, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBonus = _claimBonusP(alice, 100 * ONE);
uint256 eveBonus = _claimBonusP(eve, 50_000 * ONE);
assertGt(eveBonus, aliceBonus * 100, "late staker captured the bonus");
}

The full PoC suite also includes a control test showing that an early pokeRiskWindow() restores the intended behavior and crushes the late entrant:

forge test --match-contract FirstObserverBonusExtraction -vv

Recommended Mitigation

The best fix is to anchor riskWindowStart to the registry's actual active-risk transition timestamp instead of the pool's first observation timestamp. If that timestamp is not available from the registry, the local mitigation is to disallow new principal deposits after active risk is observed, while still allowing bonus contributions.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (block.timestamp >= expiry) revert StakingClosed();
- _assertDepositsAllowed(_observePoolState());
+ IAttackRegistry.ContractState state = _observePoolState();
+ _assertDepositsAllowed(state);
+ if (_isActiveRiskState(state)) revert StakingClosed();
...
}

This removes the first-observer deposit path. The tradeoff is that it also removes the intended ability to stake during UNDER_ATTACK; preserving that feature safely requires the registry to expose the real transition timestamp or another non-manipulable risk-window anchor.

Support

FAQs

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

Give us feedback!