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

Extreme standard ERC20 amounts can overflow k=2 stake-time accounting

Author Revealed upon completion

Root + Impact

Description

  • Normally, staking records each deposit's amount and entry timestamp so the pool can later compute the k=2 time-weighted bonus distribution. The intended accounting is amount * entryTime and amount * entryTime^2, and when the registry first reaches an active-risk state the pool snapshots the global totals as totalEligibleStake * t and totalEligibleStake * t^2.

    The issue is that these multiplications are performed directly in checked uint256 arithmetic before the later mulDiv-based payout calculation. There is no maximum token supply, decimals, per-stake amount, or total eligible stake bound. An allowlisted standard ERC20 with extremely large units can therefore accept a stake at the current timestamp and later overflow when the risk window is first observed, leaving the stake recorded while pokeRiskWindow() and withdraw() both revert.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
uint256 contribTime = received * newEntry;
@> uint256 contribTimeSq = received * newEntry * newEntry;
...
}
function _markRiskWindowStart(uint256 t) internal {
riskWindowStart = uint32(t);
@> uint256 sumStakeTime = totalEligibleStake * t;
@> uint256 sumStakeTimeSq = totalEligibleStake * t * t;
globalSumStakeTime = sumStakeTime;
globalSumStakeTimeSq = sumStakeTimeSq;
...
}

Risk

Likelihood:

  • This occurs when the factory owner allowlists a standard ERC20 with extremely large units and a user stakes an amount that is safe at the current timestamp but later exceeds type(uint256).max / block.timestamp^2 when active risk is observed.

  • The project relies on an owner-controlled token allowlist but does not enforce an on-chain maximum token supply, decimals, per-stake amount, or aggregate stake bound.

Impact:

  • pokeRiskWindow() reverts while observing UNDER_ATTACK, so riskWindowStart is not sealed and the pool cannot progress through the expected risk accounting path.

  • withdraw() also observes registry state before enforcing the withdrawal lifecycle gate, so it can hit the same overflow and leave the accepted principal stuck until the registry reaches a later state that avoids the overflowing branch.

Proof of Concept

function testAuditAcceptedExtremeStakeLaterBlocksRiskObservationAndWithdrawal() external {
uint256 futureRiskObservation = block.timestamp + 30 days;
uint256 amount = type(uint256).max / futureRiskObservation / futureRiskObservation + 1;
_stake(alice, amount);
assertEq(pool.eligibleStake(alice), amount);
vm.warp(futureRiskObservation);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.expectRevert(stdError.arithmeticError);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0);
vm.prank(alice);
vm.expectRevert(stdError.arithmeticError);
pool.withdraw();
assertEq(pool.eligibleStake(alice), amount);
}

Recommended Mitigation

Also consider enforcing an aggregate totalEligibleStake bound before accepting additional stake, because _markRiskWindowStart() depends on the total pool stake rather than only the latest deposit.

```diff
+ uint256 internal constant MAX_SUPPORTED_TIMESTAMP = type(uint32).max;
+
+ function _assertStakeTimeSafe(uint256 amount) internal pure {
+ if (amount > type(uint256).max / MAX_SUPPORTED_TIMESTAMP / MAX_SUPPORTED_TIMESTAMP) {
+ revert InvalidAmount();
+ }
+ }
+
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
+ _assertStakeTimeSafe(amount);
...
}

Support

FAQs

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

Give us feedback!