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

Late risk-window observation lets a last-second whale capture the k=2 bonus that locked early stakers earned

Author Revealed upon completion

Root + impact

Description

The bonus is split by a k=2 time-weighted formula whose entire purpose is to reward stakers in
proportion to how long their capital sat at risk. Effective entry time is floored at
riskWindowStart, and riskWindowStart is meant to mark the start of the at-risk period so that a
staker locked through a long UNDER_ATTACK window out-earns a latecomer.

riskWindowStart does not record the registry's true UNDER_ATTACK transition. It records the first
block in which some pool call happens to observe the active-risk state, and _clampUserSums
retroactively floors every earlier deposit UP to that instant. When the whole active-risk interval
elapses with no pool interaction, the first staker to touch the pool, even one block before expiry,
seals riskWindowStart at that late timestamp and drags every earlier staker's effective entry down
to it. All deposits collapse into one floored cohort and the bonus is split amount-weighted, so a
whale who bore one second of observed risk captures the pool.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
_assertDepositsAllowed(_observePoolState()); // UNDER_ATTACK deposits are allowed and seal the window
...
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
@> riskWindowStart = uint32(t); // sealed at OBSERVATION time, not the true transition
sumStakeTime = totalEligibleStake * t; // every earlier staker retroactively re-entered at t
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}
function _clampUserSums(address u) internal {
uint256 start = riskWindowStart;
uint256 stake_ = eligibleStake[u];
if (start == 0 || stake_ == 0) return;
@> if (userSumStakeTime[u] < stake_ * start) { // floors an early staker UP to a late seal,
@> userSumStakeTime[u] = stake_ * start; // erasing the real at-risk time they already served
userSumStakeTimeSq[u] = stake_ * start * start;
}
}

The withdraw escape hatch does not save the victim. withdraw() reverts from UNDER_ATTACK onward, so
the early staker is locked through the real active-risk window, and a reverted withdraw rolls back its
own observation, so it never seals the window either.

Risk

Likelihood:

  • The upstream registry holds UNDER_ATTACK indefinitely once approveAttack runs. In
    AttackRegistry._getAgreementState the attackApproved branch returns UNDER_ATTACK before the
    deadline auto-promotion branch is reached, so the unobserved active-risk interval is naturally long
    (29+ days in the PoC) and the setup arises on any pool whose stakers deposit and wait.

  • The attacker controls the timing and acts last. They wait until one block before expiry, read that
    riskWindowStart is still 0, and stake, so they only move when the precondition already holds. A
    reactive poke by the victim at that point seals just as late and does not help. The only defense is
    a proactive poke made at the true transition, which passive deposit-and-wait stakers do not make.

Impact:

  • The early staker locked for the entire real active-risk window receives under 1% of the sponsor
    bonus while a whale who bore one observed second takes over 99%. In the PoC a 100-token staker
    locked 29 days gets 9.90 of a 1,000 bonus and a 10,000-token one-second whale gets 990.10.

  • The k=2 time-weighting, the protocol's core reward mechanism, is fully defeated and collapses to
    pure amount-weighting, so bearing risk longer earns no premium over a last-second entrant.

  • The sponsor bonus is redirected away from the risk-bearing stakers it was posted to reward and to a
    non-risk-bearing latecomer. Principal is not lost.

Proof of concept

test/unit/RiskWindowSealingLead.t.sol (4 passing). test_dilution_expiredPath logs:

Alice (locked 29 days) bonus: 9.900990099009900990
Bob (1 second) bonus: 990.099009900990099009

Flow (test_dilution_expiredPath):

  1. Registry is ATTACK_REQUESTED. Alice stakes 100. Sponsor contributes a 1,000 bonus.

  2. Registry enters UNDER_ATTACK with 30 days left. Nobody interacts with the pool. riskWindowStart
    stays 0. Alice's withdraw() reverts (locked), and the reverted call does not seal the window.

  3. One second before expiry, Bob stakes 10,000. This seals riskWindowStart = expiry - 1.

  4. Both claim after expiry. Bob receives 990.10, Alice 9.90.

test_dilution_survivedPath shows the same on the SURVIVED path. test_proactivePokeDefends shows a
poke at the transition preserves Alice's bonus, and test_reactivePokeIsWorthless shows a poke made
once Bob appears does not, which is the asymmetry that distinguishes this from the accepted timing
residual in DESIGN.md section 7 (that residual is a symmetric race to observe the same terminal event
that any counterparty collapses to ~zero, no principal effect, no third-party loss; here the
defender's window closes at the unobserved transition and a whale takes ~99% of the bonus).

Recommended mitigation

The registry exposes no canonical transition timestamp, so riskWindowStart cannot be anchored to
the true transition on-chain. The direct fix is to stop a late deposit from joining the floored
cohort. Block deposits while the registry is UNDER_ATTACK, matching the existing PROMOTION_REQUESTED
block:

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
- state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
+ state == IAttackRegistry.ContractState.UNDER_ATTACK
+ || state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

This removes the whale-dilution lever. If UNDER_ATTACK deposits must stay open (DESIGN.md section 3),
instead give the window-opening deposit a strictly post-observation entry time so it cannot share the
floored cohort with pre-existing stakers, and require the moderator to supply the true active-risk
start at resolution so genuinely early stakers are not floored to a late seal.

Support

FAQs

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

Give us feedback!