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

Risk-window normalization can freeze expiry claims until registry terminalization

Author Revealed upon completion

Missing risk-window stake-domain bounds + Principal frozen until registry terminalization

Description

ConfidencePool.stake() accepts aggregate stake as long as its absolute entry-time moments fit at the deposit timestamp. When the registry later enters UNDER_ATTACK, _markRiskWindowStart() replaces those moments with
totalEligibleStake * riskWindowStart^2 without checking that the accepted aggregate is still representable at the later timestamp.

An aggregate A accepted at time E can therefore overflow one second later at E + 1. The overflow rolls back every attempt to observe the active-risk state. After the pool expires, claimExpired() reaches the same observation and panics before returning principal, even though the documented behavior requires active-risk pools to resolve EXPIRED.

A separate large staker can bring the aggregate to the unsafe boundary and freeze an independent victim's A/4 position. The freeze lasts while the agreement remains UNDER_ATTACK; a later registry transition to PRODUCTION or CORRUPTED bypasses risk-start normalization and restores settlement. This report therefore claims a conditional, potentially indefinite freeze—not a permanent loss or attacker profit.

Root cause:

stake() stores stake-weighted absolute timestamps at the current entry time:

uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;

Neither the pool nor the factory limits the token's raw supply or the aggregate timestamp-square domain. The factory only checks a boolean token allowlist
(createPool(),
setStakeTokenAllowed()).

On the first active-risk observation,
_observePoolState() calls
_markRiskWindowStart():

if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
// ...
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;

Let:

E = 2,000,000,000
A = floor((2^256 - 1) / E^2)
= 28,948,022,309,329,048,855,892,746,252,171,976,963,317,496,166,410,141,009,864

At entry, A * E^2 <= type(uint256).max, so the real stake() calls succeed. At E + 1, however:

A * (E + 1)^2 > type(uint256).max

so _markRiskWindowStart() panics. Because the whole transaction reverts, riskWindowStart remains zero and the next observation retries the same overflowing expression.

This breaks the explicit expiry guarantee. claimExpired()
calls _observePoolState() before taking its intentionally broad non-terminal EXPIRED branch:

IAttackRegistry.ContractState state = _observePoolState();
// ...
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
// Includes UNDER_ATTACK and PROMOTION_REQUESTED by design.
outcome = PoolStates.Outcome.EXPIRED;
}

The design document states that expiry during UNDER_ATTACK is normal and must return principal; it specifically rejects deferring active-state expiry because that would trap honest stakers. The arithmetic panic prevents the intended branch from being reached.

The freeze is not automatically time-bounded by the registry. In the pinned dependency,
_getAgreementState()
returns UNDER_ATTACK whenever attackApproved remains set, before checking the original
promotion deadline. Only an authorized attack moderator can call
promote()
or markCorrupted(). Thus an agreement can honestly remain attackable past the pool's independent
expiry until an external privileged actor chooses a terminal transition.

This is distinct from an overflow in the later _bonusShare() claim calculation. Rewriting that claim formula to use elapsed-time or full-precision arithmetic leaves this trace unchanged because it panics earlier while the pool is still UNRESOLVED. Conversely, a local guard or
representation change in _markRiskWindowStart() does not repair _bonusShare().

Preconditions and attack path:

  1. The factory owner allowlists a standard ERC-20 whose raw supply reaches the unsafe domain.

  2. An agreement owner creates a pool using that token.

  3. At E = 2,000,000,000, Alice stakes A/4 and Bob stakes the remaining 3A/4. Alice is the victim; Bob's deposit brings the pool-wide aggregate to A.

  4. One second later, the honest BattleChain registry enters the normal UNDER_ATTACK state.

  5. Any call that observes the state, including pokeRiskWindow(), panics in
    _markRiskWindowStart() and rolls back.

  6. The registry remains UNDER_ATTACK through the pool's expiry. Alice calls claimExpired(), which panics before resolving the pool or transferring her principal.

No malicious token callback, fee-on-transfer behavior, rebase, compromised owner, compromised moderator, corrupt registry, direct storage write, or impossible lifecycle transition is used.

With a fixed-supply OpenZeppelin ERC-20 reporting 50 decimals, A represents approximately 289.48 million display tokens and Alice's A/4 position represents approximately 72.37 million display tokens. ERC-20 decimals() is optional metadata and does not constrain raw arithmetic.

Risk

Likelihood:

  • The failure occurs when the factory allowlists a standard ERC-20 whose raw supply reaches the unsafe domain and public deposits bring aggregate stake across the arithmetic boundary.

  • A fixed-supply, no-fee, non-rebasing token reporting 50 decimals reaches the demonstrated aggregate boundary at approximately 289.48 million display tokens. The repository supports standard ERC-20s and does not constrain decimals, raw supply, or aggregate stake.

  • A normal registry transition into UNDER_ATTACK routes observation through the overflowing _markRiskWindowStart() expression. No malicious token behavior, compromised role, invalid lifecycle transition, or exact-block race is needed.

  • The unusual token parameter and large aggregate position make likelihood Medium under the CodeHawks impact/likelihood guidance.

Impact:

Alice cannot withdraw once the registry is active, and her post-expiry claimExpired() reverts before any outcome, obligation, or balance mutation. Her A/4 principal remains in the pool even though Bob supplied the stake that crossed the aggregate boundary.

The impact persists for as long as the agreement remains UNDER_ATTACK. The real registry has no automatic timeout from that state: the attack moderator must request promotion or mark the agreement corrupted, or the registry DAO must force a terminal transition. Alice cannot trigger
those repairs through the pool.

A later PRODUCTION or CORRUPTED state does restore settlement because terminal states call _markRiskWindowEnd() without calling _markRiskWindowStart(). The PoC exercises that recovery and does not claim permanent freezing, theft, or attacker profit.

The impact is Medium: funds are unavailable and the pool's documented expiry mechanism is broken, but an external authorized lifecycle transition repairs settlement. Medium impact and Medium likelihood produce Medium severity under the CodeHawks matrix.

Proof of Concept

Create test/eidolon/BCCPBS03SubmissionPoC.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {stdError} from "forge-std/StdError.sol";
contract BCCPBS03SubmissionPoC is BaseConfidencePoolTest {
uint256 private constant ENTRY_TIME = 2_000_000_000;
function test_BCCPBS03_largeStakerFreezesVictimUntilTerminalTransition() external {
uint256 totalStake = _maximumStakeAtEntry();
uint256 victimStake = totalStake / 4;
_stakeAggregate(victimStake, totalStake - victimStake);
vm.warp(ENTRY_TIME + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
vm.warp(pool.expiry());
vm.expectRevert(stdError.arithmeticError);
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(alice), 0, "victim receives no principal while active");
assertEq(pool.eligibleStake(alice), victimStake, "victim obligation remains frozen");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "resolution rolls back");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(alice), victimStake, "terminal transition repairs settlement");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED), "production resolves survived");
}
function test_BCCPBS03_quarterMagnitudeControlExpiresNormally() external {
uint256 victimStake = _maximumStakeAtEntry() / 4;
_stakeAggregate(victimStake, 0);
vm.warp(ENTRY_TIME + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(pool.expiry());
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(alice), victimStake, "control recovers principal at expiry");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "active state resolves expired");
}
function _stakeAggregate(uint256 victimStake, uint256 largeStake) private {
pool.setExpiry(ENTRY_TIME + 31 days);
vm.warp(ENTRY_TIME);
_stakeRaw(alice, victimStake);
if (largeStake != 0) _stakeRaw(bob, largeStake);
uint256 totalStake = victimStake + largeStake;
assertEq(pool.totalEligibleStake(), totalStake, "aggregate stake is accepted");
assertEq(pool.sumStakeTimeSq(), totalStake * ENTRY_TIME * ENTRY_TIME, "entry-time square fits");
}
function _maximumStakeAtEntry() private pure returns (uint256) {
return type(uint256).max / (ENTRY_TIME * ENTRY_TIME);
}
}

Recommended Mitigation

Enforce a worst-case arithmetic-domain invariant before accepting stake. Because riskWindowStart <= expiry, the aggregate required by _markRiskWindowStart() must satisfy:

totalEligibleStake <= floor(type(uint256).max / expiry^2)

Apply the check before changing user or global accumulators. Also retain a defensive bound in _markRiskWindowStart() so future accounting paths cannot create an unresolvable pool.

Alternatively, replace absolute Unix-timestamp moments with an elapsed-time representation whose range is bounded by the pool duration. Fixing only _bonusShare() does not repair this finding; It overflows earlier while opening the risk window.

Support

FAQs

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

Give us feedback!