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

Owner frontrunning staker deposits with setExpiry(type(uint32).max) leads to permanent locked staker's deposits for non registered Agreement on AttackRegistry

Author Revealed upon completion

Root + Impact

Description

  • expiry is sponsor-mutable via setExpiry only before the first stake, gated by the one-way expiryLocked latch. This is meant to protect staker reliance — once a staker has committed capital against a given deadline, the sponsor can no longer modify it.

  • expiryLocked is only flipped to true inside stake(), at the moment a stake is actually processed — not at the moment the staker decides to stake or submits their transaction. stake() itself never checks that the currently-set expiry is within any bound the staker consented to. This means a staker who staked by looking at the current config can get front-run with setExpiry(newExpiry), pushing expiry all the way to type(uint32).max (~February 2106) — a value that still satisfies both of setExpiry's own bounds checks. The staker's stake() transaction then executes immediately afterward, permanently locking in that expiry via expiryLocked = true, with no way for the staker to have prevented it.

  • When the pool registered has Agreement conract not registered on AttackerRegistry, there is no way to have any terminal state, resulting in waiting for the claimExpired function, but the time set is max of 2106 which is beyong imagination, thus leading to permanent lock of staker's funds.

    function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
    if (amount == 0) revert InvalidAmount();
    if (amount < minStake) revert BelowMinStake();
    if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
    if (block.timestamp >= expiry) revert StakingClosed();
    _assertDepositsAllowed(_observePoolState());
    @> if (!expiryLocked) {
    @> expiryLocked = true;
    @> }
    // no check that `expiry` is within any bound the staker expects
    ...
    }
    function setExpiry(uint256 newExpiry) external onlyOwner {
    @> if (expiryLocked) revert ExpiryLocked();
    if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
    @> if (newExpiry > type(uint32).max) revert ExpiryTooFar(); // allows ~year 2106
    uint256 oldExpiry = expiry;
    expiry = uint32(newExpiry);
    emit ExpiryUpdated(oldExpiry, newExpiry);
    }

Risk

Likelihood:

  • Whe the agreement owner calls the setExpiry transaction with max value, setting the expiry to its max just before a deposit is made, along with the agreement not having its registration in AttackerRegistry (No terminal state, meaning the moderator can also do nothing).

Impact:

  • Almost permanent loss of fund of the staker, as the configuration changed and the funds can't be recover until 2106, which is considered permanent fund loss for that staker.

Recommended Mitigation

Give the staker a way to bound the expiry they're willing to accept at the moment of deposit, so a front-run setExpiry call causes their transaction to revert instead of silently locking in an unbounded deadline — the same pattern used for slippage/deadline protection elsewhere:

- function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+ function stake(uint256 amount, uint256 maxExpiry) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
+ if (expiry > maxExpiry) revert ExpiryExceedsMax();
_assertDepositsAllowed(_observePoolState());
...
}

Support

FAQs

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

Give us feedback!