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

First terminal observer can choose `riskWindowEnd` and influence bonus allocation

Author Revealed upon completion

Root + Impact

Description

riskWindowEnd, the upper bound T in the k=2 bonus formula, records when a terminal registry state is first observed rather than when the registry actually became terminal. Delaying that observation increases T and redistributes the fixed bonus pool toward later stakers.

The pool intends to pin T before resolution so the timestamp of flagOutcome() or claimExpired() cannot affect bonus shares. A permissionless call to pokeRiskWindow() can eagerly record the terminal state.

However, every observation path reads only the registry's current state. The first successful call made after the registry reaches PRODUCTION or CORRUPTED permanently sets riskWindowEnd to that call's block.timestamp, capped at expiry. The registry transition time is not used.

src/ConfidencePool.sol
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
// ... scope and start-window logic omitted ...
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd(); // @> The first terminal observer permanently selects T.
}
}
function _markRiskWindowEnd() internal {
// Mirrors the cap in _markRiskWindowStart: accrual is bounded by the pool's lifecycle.
uint256 t = block.timestamp; // @> Observation time, not the terminal transition time.
if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);
emit RiskWindowEnded(t);
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt; // @> Resolution copies the selected riskWindowEnd into T.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// ... equivalent global score calculation omitted ...
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

For equal stake, an early entry at S and a later entry at S + 5 days receive weights (T-S)^2 and (T-S-5 days)^2. Sealing T at S + 10 days splits the bonus 80/20; sealing it at S + 20 days changes the split to 64/36. Thus a later staker benefits from a delayed observation while an earlier staker benefits from an immediate one.

The design document acknowledges this as an accepted timing residual because the current registry exposes no canonical terminal-transition timestamp. Permissionless poking makes manipulation a contested race, but does not make the allocation independent of transaction timing.

Risk

Likelihood:

  • The issue occurs when an active-risk window has already been observed and the registry becomes terminal without any successful pool observation until a later block.

  • A beneficiary cannot guarantee a delay because any counterparty can call pokeRiskWindow() immediately, but the protocol does not enforce or automatically execute that call.

Impact:

  • The selected timestamp can materially redistribute the fixed bonus pool between early and late stakers.

  • Principal and the total bonus amount are unaffected; the impact is limited to bonus allocation.

Proof of Concept

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

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP031TerminalObserverBonusAllocationTest is BaseConfidencePoolTest {
function test_DelayedTerminalObservationRedistributesBonusToLateStaker() external {
ConfidencePool earlyObservationPool = pool;
ConfidencePool delayedObservationPool = _deployPool();
_stakeIn(earlyObservationPool, alice, 100 * ONE);
_stakeIn(delayedObservationPool, alice, 100 * ONE);
_contributeBonusTo(earlyObservationPool, carol, 100 * ONE);
_contributeBonusTo(delayedObservationPool, carol, 100 * ONE);
// Both pools observe the same risk-window start.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
earlyObservationPool.pokeRiskWindow();
delayedObservationPool.pokeRiskWindow();
// Read the stored timestamp: Foundry's vm.warp changes block.timestamp inside one test
// transaction, while Solidity may optimize a local `block.timestamp` read as invariant.
uint256 riskStart = earlyObservationPool.riskWindowStart();
// Bob joins halfway through the real risk interval.
vm.warp(riskStart + 5 days);
_stakeIn(earlyObservationPool, bob, 100 * ONE);
_stakeIn(delayedObservationPool, bob, 100 * ONE);
// The registry becomes terminal after ten days. Only one pool is poked immediately.
vm.warp(riskStart + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
earlyObservationPool.pokeRiskWindow();
assertEq(earlyObservationPool.riskWindowEnd(), riskStart + 10 days);
assertEq(delayedObservationPool.riskWindowEnd(), 0);
// With no intervening pool call, the second pool records observation time as T.
vm.warp(riskStart + 20 days);
delayedObservationPool.pokeRiskWindow();
assertEq(delayedObservationPool.riskWindowEnd(), riskStart + 20 days);
vm.prank(moderator);
earlyObservationPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(moderator);
delayedObservationPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceEarlyBonus = _claimBonus(earlyObservationPool, alice);
uint256 bobEarlyBonus = _claimBonus(earlyObservationPool, bob);
uint256 aliceDelayedBonus = _claimBonus(delayedObservationPool, alice);
uint256 bobDelayedBonus = _claimBonus(delayedObservationPool, bob);
// Immediate observation: scores are 10^2 : 5^2, so bonus splits 80 : 20.
assertEq(aliceEarlyBonus, 80 * ONE);
assertEq(bobEarlyBonus, 20 * ONE);
// Delayed observation: scores are 20^2 : 15^2, so bonus splits 64 : 36.
assertEq(aliceDelayedBonus, 64 * ONE);
assertEq(bobDelayedBonus, 36 * ONE);
assertEq(aliceEarlyBonus + bobEarlyBonus, aliceDelayedBonus + bobDelayedBonus);
assertGt(bobDelayedBonus, bobEarlyBonus, "delaying T redistributes bonus to the late staker");
}
function _stakeIn(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 _contributeBonusTo(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 _claimBonus(ConfidencePool target, address user) internal returns (uint256 bonus) {
uint256 beforeBalance = token.balanceOf(user);
vm.prank(user);
target.claimSurvived();
bonus = token.balanceOf(user) - beforeBalance - 100 * ONE;
}
}

Run the PoC from the repository root:

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

  2. Confirm that the test passes and reports the 80/20 versus 64/36 bonus split.

Recommended Mitigation

Extend the registry to expose a canonical per-agreement terminal-transition timestamp for every path into PRODUCTION and CORRUPTED, then seal riskWindowEnd from that timestamp after validating it. This requires an upstream registry/interface change; the current state-only getter cannot eliminate the timing residual.

src/ConfidencePool.sol
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
- state = _getAgreementState();
+ uint256 terminalAt;
+ (state, terminalAt) = _getAgreementStateAndTerminalTimestamp();
// ... scope and start-window logic omitted ...
if (riskWindowEnd == 0 && _isTerminalState(state)) {
- _markRiskWindowEnd();
+ _markRiskWindowEnd(terminalAt);
}
}
-function _markRiskWindowEnd() internal {
+function _markRiskWindowEnd(uint256 terminalAt) internal {
// Mirrors the cap in _markRiskWindowStart: accrual is bounded by the pool's lifecycle.
- uint256 t = block.timestamp;
+ if (terminalAt == 0 || terminalAt > block.timestamp) revert InvalidTerminalTimestamp();
+ uint256 t = terminalAt;
if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);
emit RiskWindowEnded(t);
}

Until the registry supplies that timestamp, a keeper that calls pokeRiskWindow() on terminal transitions can narrow the observation gap but cannot remove the underlying transaction-timing dependency.

Support

FAQs

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

Give us feedback!