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

Large Allowed Stakes Can Overflow K=2 Bonus Math And Lock Survivor/Expired Claims

Author Revealed upon completion

Root + Impact

Description

After a pool resolves as SURVIVED or EXPIRED, stakers are expected to claim their principal plus any k=2 time-weighted bonus through claimSurvived() or claimExpired().

The issue is that _bonusShare() computes the quadratic score using absolute Unix timestamps and multiplies T * T * stake before reaching Math.mulDiv. Since the contract does not enforce a maximum stake amount for allowlisted ERC20s, a sufficiently large valid stake can make the claim-time score overflow and revert with panic 0x11.

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
uint256 T = outcomeFlaggedAt;
// src/ConfidencePool.sol:704
@> uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// src/ConfidencePool.sol:708
@> uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;

Risk

Likelihood:

  • This occurs when the factory allowlists a standard ERC20 whose base-unit supply and holder distribution can reach approximately type(uint256).max / (2 * block.timestamp * block.timestamp), around 1.89e58 base units at current timestamps.

  • This occurs after the risk window has started and the pool later resolves as SURVIVED or EXPIRED with a nonzero bonus, causing _bonusShare() to execute the overflowing k=2 path.

Impact:

  • Normal stakers cannot claim their principal or bonus because claimSurvived() and claimExpired() revert with arithmetic panic 0x11.

  • Once the registry has reached UNDER_ATTACK, withdrawals are disabled, so affected users have no alternate withdrawal path and their stake remains locked in the pool.

Proof of Concept

function testPoC_largeStakeLocksNormalStakerSurvivedClaim() external {
uint256 stakeAmount = type(uint256).max / (2 * block.timestamp * block.timestamp);
_stake(alice, stakeAmount);
_stake(bob, 100e18);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 1 days);
_contributeBonus(carol, 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 bobBalanceBefore = stakeToken.balanceOf(bob);
vm.prank(bob);
vm.expectRevert(stdError.arithmeticError);
pool.claimSurvived();
assertEq(stakeToken.balanceOf(bob), bobBalanceBefore);
assertEq(pool.eligibleStake(bob), 100e18);
assertEq(stakeToken.balanceOf(address(pool)), stakeAmount + 100e18 + 1);
}

Run:

forge test --match-path test/poc/ConfidencePoolOverflowPoC.t.sol -vv
BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-path test/fork/ConfidencePoolOverflow.fork.t.sol -vv

Both local and fork PoCs pass.

Recommended Mitigation

Compute the k=2 score using bounded time deltas relative to riskWindowStart, not absolute Unix timestamps.

- uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
- uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
+ uint256 elapsed = T - riskWindowStart;
+ uint256 userPlus = elapsed * elapsed * userEligible + userRelativeSumStakeTimeSq[u];
+ uint256 plus = elapsed * elapsed * snapshotTotalStaked + snapshotRelativeSumStakeTimeSq;

Also add regression tests for large allowed stakes in both claimSurvived() and claimExpired().

Support

FAQs

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

Give us feedback!