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

Risk-bearing stakers forfeit their entire bonus when the active-risk window is never observed

Author Revealed upon completion

Root + impact

Description

Bonus accrual is meant to reward stakers for time spent at risk. _bonusShare pays a k=2
time-weighted share to any staker who was locked while the agreement was attackable. DESIGN section 9
justifies the "no observed risk, no bonus" rule by asserting that a staker "only forfeits the exit
option once risk has actually materialized, which is exactly when they begin earning the risk
premium," so forfeiting the exit and earning the premium are presented as the same event.

They are not the same event. withdraw() is disabled by the live registry state from UNDER_ATTACK
onward, independent of riskWindowStart, so the staker forfeits the exit the instant the agreement
is attackable. But _bonusShare returns zero unless riskWindowStart != 0, which only happens if
some pool call observed the active-risk state. When the registry passes through the whole active-risk
window and reaches a terminal state with no pool interaction, riskWindowStart stays zero, the locked
stakers earn zero premium, and the entire bonus sweeps to recoveryAddress.

// src/ConfidencePool.sol :: withdraw -> exit forfeited at UNDER_ATTACK regardless of riskWindowStart
if (
riskWindowStart != 0
@> || (state != NOT_DEPLOYED && state != NEW_DEPLOYMENT && state != ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
// src/ConfidencePool.sol :: _bonusShare -> premium paid ONLY if the window was observed
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
@> if (riskWindowStart == 0) return 0; // locked-but-unobserved staker earns nothing

Risk

Likelihood:

  • The registry reaches a terminal state after an active-risk window that no pool call observed. This
    arises whenever a pool of deposit-and-wait stakers sees the agreement go UNDER_ATTACK and then
    PRODUCTION while nobody stakes, withdraws, pokes, or claims in between. Nobody is required to poke.

  • A staker who tries to withdraw during UNDER_ATTACK reverts (locked), and the reverted call rolls
    back its own observation, so it does not seal the window either.

Impact:

  • Stakers locked through the full real active-risk window receive zero bonus; the entire sponsor
    bonus sweeps to recoveryAddress. In the PoC a staker locked through UNDER_ATTACK gets 0 of a 500
    bonus.

  • No principal is lost, and any single pokeRiskWindow during the window prevents it, which caps
    this at the low end.

Proof of concept

test/unit/FableAuditLeads.t.sol:

  • test_lead_bonusForfeited_despiteLockedRisk (passing): Alice stakes 100, sponsor funds a 500
    bonus, registry goes UNDER_ATTACK (Alice's withdraw reverts, proven), survives to PRODUCTION with
    zero pool interaction, moderator flags SURVIVED. Alice claims principal + 0 bonus; the full 500
    sweeps to recovery.

  • test_lead_singlePokeSecuresBonus (passing, control): one pokeRiskWindow during UNDER_ATTACK
    gives Alice all 500.

Alice bonus after bearing the locked UNDER_ATTACK window: 0.000000000000000000
Alice bonus WITH one poke: 500.000000000000000000

Recommended mitigation

Do not gate the premium on whether the window was locally observed when the staker's exit was already
disabled by registry state. Either seal riskWindowStart from moderator-supplied transition data at
resolution, or drop the section 9 exit-forfeiture-equals-premium claim and document that stakers must
poke to earn the premium. The same fix that addresses the risk-window sealing dilution resolves this.

- // DESIGN section 9: forfeiting the exit "is exactly when they begin earning the risk premium"
+ // Exit is forfeited by registry state at UNDER_ATTACK; the premium must not additionally depend
+ // on a lazy local observation the locked staker cannot always make.

Support

FAQs

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

Give us feedback!