Root + Impact
Description
**Affected functions:** `_bonusShare`, `claimSurvived`, `claimCorrupted`, `claimExpired`, `stake`, `contributeBonus`
**Affected actors:** Every eligible staker in an affected pool; the pool's entire principal
### Description
This finding lives in an adjacent arithmetic band from the seal-path sibling. There, aggregate stake is large enough that `_markRiskWindowStart` overflows and the seal never happens. Here, aggregate stake is a step smaller: `_markRiskWindowStart` succeeds (`received * t_risk² < 2^256`, so `riskWindowStart != 0`), but `_bonusShare` at claim time computes intermediate sums like `T² * snapshotTotalStaked + snapshotSumStakeTimeSq` that exceed `2^256` and revert. Every claimant, including honest stakers whose own positions are individually safe, cannot call `claimSurvived` or `claimCorrupted`. Principal is trapped.
Arithmetic bands, with `t_stake ≈ BASE_TIMESTAMP` and `T = riskWindowEnd = t_risk`:
- **Sibling band (seal-path):** `received * t_risk² ≥ 2^256`. Seal reverts; the claim path is never exercised.
- **This band (claim-path):** `received * t_risk² < 2^256` *and* `received * (T² + t_risk²) ≥ 2^256`. Seal succeeds; the later claim math reverts.
``solidity
``
`claimSurvived` (`:393`) and `claimCorrupted` (`:408-425`) both consult `_bonusShare` via per-user sums. A revert in the k=2 evaluation traps 100% of stakers' principal.
The historical framing was that this and the seal-path sibling are mutually exclusive. That framing is wrong: the two overflows sit in adjacent (non-overlapping) bands of `received`, not the same band. A partial fix at the seal site does not close this one.
### Invariant violated
Principal is always claimable through at least one path (`claimSurvived`, `claimCorrupted`, `claimExpired`, or `withdraw` pre-risk). This finding closes every principal-bearing exit for the entire pool: `withdraw` is closed post-risk (correctly, because the window sealed), and every claim entrypoint routes through `_bonusShare`. There is no alternate exit, and pool clones are non-upgradeable.
### Attack path
1. Factory owner allowlists a behaviorally standard ERC-20 whose supply admits raw balances in the claim-path band.
2. Attacker deposits an amount that satisfies the band condition: `received * t_risk² < 2^256` but `received * (T² + t_risk²) ≥ 2^256`.
3. Registry advances to `UNDER_ATTACK`. `pokeRiskWindow()` succeeds; `riskWindowStart != 0`.
4. Registry reaches a terminal state. `flagOutcome` succeeds. Snapshot globals freeze.
5. Any honest staker calls `claimSurvived` or `claimCorrupted`. `_bonusShare` overflows in `T² * snapshotTotalStaked + snapshotSumStakeTimeSq`. The claim reverts with `Panic(0x11)`.
6. Attacker's own claim also reverts. Symmetric griefing. No exit exists for anyone.
Same feasibility gate as the seal-path sibling: only the size of `received` differs.
### Why this is not an accepted DESIGN.md behavior
DESIGN.md accepts the O(1) expanded-moment formula as equivalent to a per-deposit score computation. It does not accept a state where the principal-returning claim path is arithmetically unreachable while the risk window sealed successfully. Amount-weighted fallback under `globalScore == 0` is documented, but that fallback is never reached here because the panic occurs in the positive intermediates that precede the fallback branch.
Risk
Likelihood:
~ Requires the factory allowlist to admit a high-supply or high-decimal token, same gate as the seal-path sibling.
~ No malicious factory owner, moderator compromise, or nonstandard token behavior is required.
~ Once the token is admitted, any depositor whose amount falls in the claim-path band deterministically triggers the condition.
Impact:
~ Principal-freeze for every honest staker. Both `claimSurvived` and `claimCorrupted` call `_bonusShare`; a revert traps 100% of stakers' principal.
~ Attacker retains own principal but pays symmetric cost. Own claim also reverts.
~ No emergency exit. Pool clones are non-upgradeable.
Proof of Concept
``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;
vm.prank(attacker);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
}``
| Scenario | Test |
| --- | --- |
| Claim overflows in a band where the seal succeeded | `test_CP003_reachable_withRiskWindowSealed` |
The test picks a `received` in the band where `_markRiskWindowStart` succeeds (asserting `riskWindowStart != 0`), then shows `claimSurvived()` reverts with `Panic(0x11)`. The test falsifies the prior "mutually exclusive with the seal-path sibling" framing.
```bash
forge test --match-path test/audit/CP003_IndependentReachability.t.sol -vvvv
```
### Observed result
```text
[PASS] test_CP003_reachable_withRiskWindowSealed()
Suite result: ok. 1 passed; 0 failed; 0 skipped
```
Recommended Mitigation
Same fix as the seal-path sibling. Bound the projected `t²` term at deposit time using `expiry²` as the ceiling.
```diff
+error StakeExceedsArithmeticBound();
+
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ... existing checks ...
+
+ uint256 tMax = uint256(expiry);
+ uint256 headroom = type(uint256).max - sumStakeTimeSq;
+ if (received > headroom / (tMax * tMax)) revert StakeExceedsArithmeticBound();
+
_clampUserSums(msg.sender);
// ... existing state updates ...
}
```
Because the claim math's largest term is `received * expiry²`, ensuring `received * expiry² + sumStakeTimeSq < 2^256` at every accepted deposit dominates both trigger sites. Guarding only `_markRiskWindowStart` (a defensive `unchecked` shim, or a delta-based partial sum) leaves this claim path exploitable. Reviewers should verify the fix covers both sites.
### Regression properties
~ Aggregate stake at the documented capacity completes `claimSurvived` and `claimCorrupted` without overflow when bonus is nonzero.
~ One raw unit above the capacity reverts atomically in `stake()` before any principal or moment is persisted.
~ Existing pools with conventional-supply tokens remain unaffected.