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

A sole last-block staker can capture the entire bonus of a dormant pool

Author Revealed upon completion

Root + Impact

The pricing formula is designed to punish late stakers, but relies on the implicit assumption that there will always be some early stakers. In the case where a pool failed to attrack any stakers, any stake contributed in the last eligible block will cause userScore == globalScore regardless of how little absolute exposure that score represents, so the depositor receives 100% of the bonus. This creates a discontinuity: with zero stakers the bonus returns to recoveryAddress, while one minimally exposed staker receives it all. A bot can therefore extract bonuses from dormant pools without providing the sustained capital-at-risk the incentive was intended to attract.

Description

  • Deposits are allowed during UNDER_ATTACK, the agreement's normal operating mode (DESIGN.md §1/§3), because the k=2 time-at-risk weighting is expected to crush a late entrant's bonus share relative to earlier stakers. After expiry, claimExpired resolves any non-terminal state as EXPIRED and pays principal plus bonus share.

  • The weighting only differentiates between competing stakers. In a pool with no earlier stake, the sole late staker is both the numerator and denominator of the share formula, so normalization erases the effect of their near-zero absolute score. A bot can monitor pools with a positive bonus and zero eligible stake, contribute minStake in the final block satisfying block.timestamp < expiry, and claim at or after expiry. Its stake is exposed only during the final block interval. The same relative-score failure applies to a SURVIVED resolution when a sole staker enters shortly before the risk window ends.

// src/ConfidencePool.sol::_assertDepositsAllowed — UNDER_ATTACK is not blocked
@> if (state == PROMOTION_REQUESTED || state == PRODUCTION || state == CORRUPTED) revert StakingClosed();
// UNDER_ATTACK is absent, so a deposit at expiry - ε is accepted
// src/ConfidencePool.sol::claimExpired — any non-terminal state pays out as EXPIRED
@> outcome = PoolStates.Outcome.EXPIRED;
// src/ConfidencePool.sol::_bonusShare — share is priced only relative to the global score
@> return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
// sole staker: userScore == globalScore, so the share is 100% of the bonus

Risk

Likelihood:

  • Occurs when a pool holds a sponsor bonus, has no eligible stakers near expiry, and remains in a state that accepts deposits. Such dormant pools are plausible for unpopular agreements, and their bonus, stake, state, and expiry are all observable on-chain, making automated targeting straightforward.

  • The bot must commit the pool's configured minStake for the final eligible block interval. This is not literally risk-free: the stake can be lost if the agreement becomes CORRUPTED, and transaction inclusion or ordering can prevent the intended timing. These constraints keep likelihood Low but do not remove the structural extraction opportunity.

Likelihood is Low.

Impact:

  • The full sponsor bonus is diverted to a latecomer who provided negligible temporal exposure rather than the sustained capital-at-risk the bonus was intended to attract. The counterfactual is the zero-staker path, where the bonus returns to recoveryAddress, so the sponsor's loss equals the entire bonus.

  • The strategy is repeatable across qualifying dormant pools and can be automated.

Impact is Medium.

Proof of Concept

Illustrative sequence:

function test_poc_soleLastBlockStakerCapturesWholeBonus() external {
_contributeBonus(sponsor, BONUS); // pool holds a bonus, nobody has staked
_passThroughUnderAttack(); // agreement's normal mode; deposits still allowed
vm.warp(pool.expiry() - 1); // final eligible timestamp
_stake(arb, pool.minStake());
vm.warp(pool.expiry()); // resolve at expiry
uint256 before = token.balanceOf(arb);
vm.prank(arb);
pool.claimExpired();
// Sole staker: principal returned plus 100% of the bonus.
assertEq(token.balanceOf(arb) - before, pool.minStake() + BONUS);
}

Recommended Mitigation

Bonus eligibility must depend on absolute exposure, not only on a staker's score relative to other stakers. Two direct options are:

  1. Block new stake after the active-risk window begins, or impose a minimum lead time before expiry. The latter prevents the expiry capture described above but does not guarantee minimum exposure when a SURVIVED resolution ends the risk window early.

  2. If deposits must remain open throughout UNDER_ATTACK, track bonus eligibility per deposit and exclude any deposit that has not completed a minimum observed at-risk duration when the outcome is resolved. Its principal remains claimable, while the corresponding unearned bonus remains available for sweepUnclaimedBonus.

Support

FAQs

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

Give us feedback!