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

An honest staker locked under live UNDER_ATTACK forfeits 100% of their earned bonus to the sponsor's recoveryAddress when the risk window was never sealed — DESIGN §9's "forfeit-exit ⟺ earn-premium" coupling is false

Author Revealed upon completion

Root + Impact

Description

  • DESIGN §9 justifies the "no observed risk → no bonus" rule as fair by coupling two events: "a staker only forfeits the exit option once risk has actually materialized, which is exactly when they begin earning the risk premium." Under that promise a locked-in staker is, by construction, already earning the premium.

  • The code decouples them. Exit dies on LIVE registry state — withdraw() reverts WithdrawsDisabled the instant the registry is UNDER_ATTACK, even while riskWindowStart == 0 (:293-300). But the premium is earned only via a SEPARATE one-way latch — _bonusShare pays zero while riskWindowStart == 0 (:699), and that latch is sealed only by a state-mutating observation (pokeRiskWindow/stake/contributeBonus) landing during the active-risk interval. So a staker can be locked (exit dead) yet earning nothing, the exact state §9 says cannot exist. The trap is that the intuitive defense fails twice: a staker who sees UNDER_ATTACK and calls withdraw() (a) reverts, and (b) the _observePoolState() seal inside withdraw is rolled back with the revert — so it neither exits nor seals. Only the non-obvious pokeRiskWindow() seals it, and the beneficiary of the un-sealed window is the conflicted sponsor (recoveryAddress), economically incentivized not to poke.

// withdraw() — exit dies on LIVE state even while riskWindowStart == 0
IAttackRegistry.ContractState state = _observePoolState(); // seals RWS, but...
if (
riskWindowStart != 0
|| (state != NOT_DEPLOYED && state != NEW_DEPLOYMENT && state != ATTACK_REQUESTED)
@> ) revert WithdrawsDisabled(); // ...revert rolls the RWS seal back
// _bonusShare — no premium while the window is unsealed
@> if (riskWindowStart == 0) return 0;
// sweepUnclaimedBonus — with RWS==0 only principal is reserved, so the WHOLE bonus sweeps to recovery
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
@> if (riskWindowStart != 0) reserved += snapshotTotalBonus - claimedBonus; // skipped when RWS==0
}

Risk

Likelihood:

  • The agreement enters an active-risk state (UNDER_ATTACK / PROMOTION_REQUESTED) and later resolves SURVIVED — normal operation.

  • No party calls pokeRiskWindow/stake/contributeBonus during that interval, so riskWindowStart stays 0 — plausible on a thin pool, a fast transition, or when the only sophisticated actor is the conflicted sponsor who benefits from the window staying unsealed.

Impact:

  • The honest staker was locked (bore real, involuntary at-risk time) yet receives 0 bonus — 100% of the reward they earned is redirected to the sponsor's recoveryAddress. Loss = the full contributed bonus pool.

Proof of Concept

// test/rig/BonusToSponsorNoObservedRisk.t.sol (green on pristine)
// Alice stakes pre-attack; a donor funds a 100e18 bonus; registry enters UNDER_ATTACK
// (Alice's withdraw() reverts — she is locked); nobody pokes, so riskWindowStart == 0; then SURVIVED:
assertEq(pool.riskWindowStart(), 0, "risk window never sealed (no interaction during UNDER_ATTACK)");
assertEq(aliceGain, 100e18, "honest, correct, risk-bearing staker gets principal ONLY, 0 bonus");
assertEq(token.balanceOf(sponsorRecovery), 100e18, "100% of the bonus captured by the sponsor");
// confirmed separately: a withdraw() attempt during UNDER_ATTACK reverts AND leaves riskWindowStart == 0
// (the _observePoolState seal is rolled back), so the intuitive defense does not secure the premium.

Recommended Mitigation

function _markRiskWindowStart() internal {
...
}
- // exit-lock (withdraw) and premium (riskWindowStart) are gated on different conditions
+ // Couple the two events §9 promises: seal the risk window at the moment the exit-lock engages,
+ // e.g. persist the _observePoolState() seal from a failed withdraw() (currently rolled back with
+ // the revert), OR gate both the exit-lock and the premium on the same first active-risk observation,
+ // so a locked staker is always an earning staker.

Honest caveat (disclosed for the judge)

A locked staker can self-heal by calling pokeRiskWindow() themselves during the active-risk interval, so the permanent loss requires that free permissionless step to be missed by everyone. This is why the finding is filed as Low (contested Medium): the loss is self-preventable, but (a) §9's stated coupling is factually false, (b) the intuitive defense (withdraw) both fails and fails-to-seal, and (c) the beneficiary is the conflicted sponsor — so "just poke" is neither obvious nor incentive-aligned.

Support

FAQs

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

Give us feedback!