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

First Staker Near Expiry Can Steal Entire Bonus Pool With Near-Zero Risk

Author Revealed upon completion

Summary

A pool with no stakers and an unobserved UNDER_ATTACK agreement state can be exploited by a single attacker who stakes just before expiry. As the first and only staker, they trigger riskWindowStart, become the sole bonus recipient, and claim 100% of the bonus pool after expiry, having been at risk for only seconds.

Root Cause

No staking buffer enforced when agreement is in UNDER_ATTACK:

// Only blocks at exact expiry, no buffer for active-risk state
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState()); // UNDER_ATTACK is allowed

Since attacker is the only staker:

userScore == globalScore
bonusShare == 100% of snapshotTotalBonus

Code snippet-
https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L226-L227

Impact

Attacker drains 100% of bonus pool with near-zero time at risk. Bonus contributors (sponsors) lose all contributed funds.

PoC

Setup:

  • Pool has 1000 USDC bonus contributed by sponsor

  • Agreement enters UNDER_ATTACK at T0

  • All previous stakers withdrew (or pool was always empty)

  • riskWindowStart = 0 (UNDER_ATTACK never observed on pool)

Attack:

  1. Attacker monitors registry for UNDER_ATTACK state

  2. Waits until block.timestamp = expiry - 1 second

  3. Calls stake(100 USDC) → minimum stake

    _observePoolState() → UNDER_ATTACK observed for first time

    _markRiskWindowStart() sets riskWindowStart = expiry - 1

    → sumStakeTime = 0, sumStakeTimeSq = 0 (no prior stakers)

    → attacker is only staker in pool

  4. 1 second passes → expiry reached

  5. Attacker calls claimExpired()

    → UNDER_ATTACK is non-terminal → outcome = EXPIRED

    → outcomeFlaggedAt = expiry (T)

    → Bonus calculation:

    userScore = T² × 100 - 2T × userSumStakeTime + userSumStakeTimeSq

    globalScore = T² × 100 - 2T × sumStakeTime + sumStakeTimeSq

    userScore == globalScore (only staker)

    bonusShare = 100/100 × 1000 USDC = 1000 USDC

  6. Attacker receives: 100 USDC (principal) + 1000 USDC (bonus) = 1100 USDC

    Profit: 1000 USDC for 1 second of risk

Mitigation

Add a minimum staking buffer before expiry when pool is in active-risk state:

uint256 private constant _STAKE_BUFFER = 1 days;
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
IAttackRegistry.ContractState state = _observePoolState();
_assertDepositsAllowed(state);
// Buffer: prevent late staking during active-risk window
if (
state == IAttackRegistry.ContractState.UNDER_ATTACK &&
block.timestamp >= expiry - _STAKE_BUFFER
) {
revert StakingTooLate();
}
// rest of function unchanged...
}

This ensures any staker during UNDER_ATTACK has at least 1 day of meaningful risk exposure before expiry, making last-second bonus sniping economically unviable.

Support

FAQs

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

Give us feedback!