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

Lazy Observation Clamp Bypass Bypasses K=2 Penalty

Author Revealed upon completion

Lazy Observation Clamp Bypass Causes a Bypass of K=2 Penalty

Description

DESIGN.md §3 states that a staker depositing during UNDER_ATTACK has their bonus "crushed to ~zero," since entry is floored at riskWindowStart ≈ now. This assumes riskWindowStart is sealed close to the true attack start. But if nobody interacts with the pool when UNDER_ATTACK begins, riskWindowStart stays 0 until someone triggers _observePoolState(). An attacker can simply wait and let their own stake() call be that trigger — sealing riskWindowStart at their own entry time. The eager reset in _markRiskWindowStart then retroactively re-floors every existing staker to that same late timestamp, erasing all time-based differentiation.

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
@> sumStakeTime = totalEligibleStake * t;
@> sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

Risk

Likelihood:

  • Occurs whenever UNDER_ATTACK begins and no one calls the permissionless pokeRiskWindow() before the attacker deposits — the default state unless someone pays gas purely to protect other stakers.

  • Requires only one unprivileged stake() call; no governance action, rewind, or race against another user needed.

Impact:

  • The k=2 penalty collapses to plain amount-weighting: early and late entrants score identically once the attacker seals the window at their own timestamp.

  • An attacker's payout is then bounded only by deposit size, not entry time. PoC shows an attacker depositing 2× an honest staker's stake receives 2× the bonus (40 vs 20 tokens) despite zero time at risk — a 50% cut to the honest staker's bonus versus the correct baseline (140 → 120).

Proof of Concept

The PoC below pairs a control run (window sealed honestly via an immediate pokeRiskWindow() call) against an exploit run (window left lazy, sealed instead by the attacker's own stake). Both use identical timing and stakes so the only variable is who/when the window is sealed — isolating the bug's effect on the payout split.

// Control: honest observation
function test_ClampBypass_CONTROL_HonestObservation() external {
_stake(bob, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
vm.warp(BASE_TIMESTAMP + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // honest, immediate seal
vm.warp(BASE_TIMESTAMP + 20 days);
_stake(alice, 100 * ONE);
vm.warp(BASE_TIMESTAMP + 30 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob); pool.claimSurvived();
vm.prank(alice); pool.claimSurvived();
assertEq(token.balanceOf(bob), 140 * ONE); // Bob: 4x Alice's bonus, correct
assertEq(token.balanceOf(alice), 110 * ONE);
}
// Exploit: attacker's own stake() seals the window at their own timestamp
function test_ClampBypass_EXPLOIT_AliceStakesMore() external {
_stake(bob, 100 * ONE);
_contributeBonus(carol, 60 * ONE);
vm.warp(BASE_TIMESTAMP + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// no poke — window stays unsealed
vm.warp(BASE_TIMESTAMP + 20 days);
_stake(alice, 200 * ONE); // this call seals riskWindowStart at day 20
vm.warp(BASE_TIMESTAMP + 30 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob); pool.claimSurvived();
vm.prank(alice); pool.claimSurvived();
assertEq(token.balanceOf(bob) - 100 * ONE, 20 * ONE); // Bob's bonus cut in half
assertEq(token.balanceOf(alice) - 200 * ONE, 40 * ONE); // Alice: 2x bonus for 2x stake, zero time penalty
}

Recommended Mitigation

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
- _observePoolState();
+ bool sealedNow = riskWindowStart == 0;
+ _observePoolState();
+ if (sealedNow && riskWindowStart != 0) {
+ uint256 bounty = totalBonus / 200; // 0.5% keeper bounty
+ totalBonus -= bounty;
+ stakeToken.safeTransfer(msg.sender, bounty);
+ }
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
- if (riskWindowStart == 0 && _isActiveRiskState(state)) {
+ if (riskWindowStart == 0 && _isActiveRiskState(state) && msg.sig != this.stake.selector) {
_markRiskWindowStart();
}
...
}

stake() is removed as a valid trigger for sealing riskWindowStart, so a depositor can never define their own entry baseline. Sealing now requires a separate pokeRiskWindow() call, which pays a small bounty from the bonus pool — incentivizing keepers or honest parties to seal the window the moment UNDER_ATTACK begins, before an attacker gets the chance to wait it out. This reduces the exploit's economic viability rather than eliminating it outright, since the registry still exposes no canonical historical transition timestamp for a fully deterministic fix.

Support

FAQs

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

Give us feedback!