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

k=2 bonus score overflows before it cancels, permanently freezing every claim in a pool

Author Revealed upon completion

Description

Bonus is split across stakers with a k=2 time weighting: each deposit's share is proportional to stake * (T - entryTime)^2, rebuilt at claim time from running sums.

_bonusShare builds that score in expanded form and adds the two positive terms before the negative cross-term cancels them. When the observed window is short the positive sum is roughly 2 * T * T * stake, so it can overflow uint256 and revert with Panic(0x11) before the trailing Math.mulDiv ever runs. The global term uses snapshotTotalStaked, so one oversized stake poisons the denominator every claimant shares. The same pattern sits in _markRiskWindowStart (totalEligibleStake * t * t), which reverts every registry observation once the window opens.

uint256 T = outcomeFlaggedAt;
@> 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; // shared denominator
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;

To head off the obvious objection: this isn't the balance-freezing token the allowlist is documented to exclude (DESIGN.md §12). The token behaves normally and the revert is in the pool's own scoring math, not the token's transfers. And §7's note that the k=2 multiplies "widen to uint256 first" is about the uint32 timestamps, not this uint256 product — which the existing testBonusShareDoesNotOverflowAtHighMagnitudes only exercises up to 1e34, far below where this bites.

Risk

Likelihood: Triggers once a pool's total staked amount reaches about uint256.max / (2 * T^2) base units (~1.8e58 for a normal 1-2 year pool). That threshold is only reachable when the factory owner has allowlisted a stake token whose base-unit magnitudes get that high, meaning pathological decimals or supply that no standard 18-decimal token comes within ~30 orders of magnitude of. That single precondition holds the likelihood to Low. The trigger is otherwise a plain permissionless stake() before the pool resolves: no privileged role, no registry manipulation, no special timing.

Impact: Every SURVIVED/EXPIRED claim in the pool reverts on the shared-denominator overflow, so all stakers, including an honest one who deposited a single token, permanently lose principal and bonus. The clones are non-upgradeable and T is frozen at resolution, so there is no recovery path; sweepUnclaimedBonus keeps the liabilities reserved but cannot touch the math. The _markRiskWindowStart site also reverts every registry observation, claimExpired included, blocking exits for all stakers while the agreement stays in an active-risk state.

Proof of Concept

Save as test/unit/OverflowPoC.t.sol and run forge test --match-contract OverflowPoCTest -vvv. It passes against the unmodified contracts on the existing fixtures. The attacker stakes an extreme amount of a high-magnitude allowlisted token, an honest staker puts in one token, the pool resolves SURVIVED normally, and the honest staker can never claim.

// 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 OverflowPoCTest is BaseConfidencePoolTest {
function test_honestStakerCanNeverClaim() external {
_stake(alice, 2e58); // attacker, in a high-magnitude allowlisted token
_stake(carol, ONE); // honest staker, one token
_contributeBonus(dave, 100 * ONE);
_passThroughUnderAttack(); // opens the risk window
vm.warp(pool.expiry());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// carol did nothing wrong and can never get her token back:
vm.prank(carol);
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
pool.claimSurvived();
}
}

Recommended Mitigation

Bound the aggregate at deposit so every later T^2 * totalEligibleStake product stays in range. In stake(), right after the received checks:

if (received < minStake) revert BelowMinStake();
// keep the k=2 score terms in range for every t, T <= expiry
if (totalEligibleStake + received > type(uint256).max / (2 * uint256(expiry) * uint256(expiry))) {
revert StakeExceedsScoreBound();
}

Or drop the expanded form and accumulate stake * (T - entryTime)^2 directly (or run the terms through 512-bit math) so the intermediate can't overflow before it cancels. The trailing Math.mulDiv can't help either way, since it's downstream of the overflow.

Support

FAQs

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

Give us feedback!