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

Missing Stake Bound Allows Quadratic Bonus-Score Overflow and Claim Denial of Service

Author Revealed upon completion

Root + Impact

Description

The pool uses a k=2 bonus formula. At resolution, _bonusShare evaluates the
expanded score with separately accumulated terms:

userPlus = T * T * userEligible + userSumStakeTimeSq[u];

The original contract has no stake-size bound. A straightforward mitigation
would limit total stake to approximately type(uint256).max / expiry²; that is
sufficient for the individual terms, including the risk-window snapshot
totalEligibleStake * t * t, but not for their addition above.

When the risk window begins shortly before expiry and the outcome is observed a
second later, both terms are approximately stake * expiry². Therefore, a
stake greater than approximately type(uint256).max / (2 * expiry²) overflows
when a survivor claims a non-zero bonus. This amount would also pass the naive
type(uint256).max / expiry² deposit cap described above.

function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
// ...
uint256 T = outcomeFlaggedAt;
@> uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
// ...
}

Risk

Likelihood: Low for a conventional fixed-supply token: the required amount
is still astronomically large (about 1.88e58 base units with this suite's
timestamp and expiry). The factory allowlists stake tokens, but it does not
enforce a token supply bound, so the arithmetic invariant should not rely on a
particular token's economics.

Impact: A sufficiently large stake can overflow either the risk-window
snapshot or, as demonstrated below, claimSurvived() once the pool has a
non-zero bonus. The PoC deliberately keeps the snapshot terms in range and
demonstrates the later claim-path denial of service.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {stdError} from "forge-std/Test.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract AuditOverflowPoC is BaseConfidencePoolTest {
function testStakeBelowAdmissionCapOverflowsSurvivorClaim() external {
// This amount fits each risk-window snapshot term. It would also pass a
// naive max / expiry^2 admission cap, but `_bonusShare` later adds the two
// quadratic terms: T^2 * stake + stake * riskWindowStart^2.
uint256 end = pool.expiry() - 1;
uint256 start = end - 1;
uint256 attackerStake = type(uint256).max / (end * end + start * start) + 1;
// This stake is approximately half of max / expiry^2 and is accepted.
_stake(bob, attackerStake);
vm.warp(start);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
// `_bonusShare` returns early when no bonus exists, so contribute one base unit.
_contributeBonus(carol, 1);
vm.warp(end);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Both addends fit alone, but their sum exceeds uint256 max.
vm.expectRevert(stdError.arithmeticError);
vm.prank(bob);
pool.claimSurvived();
}
}

Run it with:

forge test --match-contract AuditOverflowPoC -vv

Recommended Mitigation

Add a deposit-time limit that accounts for the addition of two quadratic terms.
Cap the total eligible stake at half the individual-term bound:

+// Keep both terms of the k=2 score and their sum in uint256 range.
+uint256 capT = uint256(expiry);
+uint256 maxAllowed = capT == 0 ? type(uint256).max : type(uint256).max / 2 / capT / capT;
+if (totalEligibleStake + received > maxAllowed) revert RiskWindowOverflow();

This ensures that T² * stake + stake * entryTime² and the equivalent global
score expression fit for all T and entry times bounded by expiry. Add a
regression test that verifies this stake is rejected after the change, while
normal bonus-distribution tests continue to pass.

Support

FAQs

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

Give us feedback!