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();
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _markRiskWindowEnd() internal {
uint256 t = block.timestamp;
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;
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
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:
Proof of Concept
Create test/audit/CP031TerminalObserverBonusAllocation.t.sol with the following contents:
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);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
earlyObservationPool.pokeRiskWindow();
delayedObservationPool.pokeRiskWindow();
uint256 riskStart = earlyObservationPool.riskWindowStart();
vm.warp(riskStart + 5 days);
_stakeIn(earlyObservationPool, bob, 100 * ONE);
_stakeIn(delayedObservationPool, bob, 100 * ONE);
vm.warp(riskStart + 10 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
earlyObservationPool.pokeRiskWindow();
assertEq(earlyObservationPool.riskWindowEnd(), riskStart + 10 days);
assertEq(delayedObservationPool.riskWindowEnd(), 0);
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);
assertEq(aliceEarlyBonus, 80 * ONE);
assertEq(bobEarlyBonus, 20 * ONE);
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:
-
Execute forge test --offline --match-path test/audit/CP031TerminalObserverBonusAllocation.t.sol -vv.
-
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.