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

Post-expiry withdraw path can bypass mechanical settlement and leave bonus recoverable

Author Revealed upon completion

Root + Impact

Description

After the pool has passed expiry, a staker can still exit through withdraw() before the pool is mechanically resolved through claimExpired().

This creates two different post-expiry routes for the same unresolved no-risk state:

  • withdraw() removes the staker and pays principal only;

  • claimExpired() later resolves the pool and the remaining bonus can become recoverable through the sweep path.

Affected route:

  • withdraw()

  • claimExpired()

  • sweepUnclaimedBonus()

The issue is that the post-expiry settlement boundary is not closed before the pre-risk withdraw path remains available. This allows a staker to be removed from accounting after expiry but before mechanical resolution.

Risk

Likelihood

Medium.

This can occur when the pool is expired, the registry is still in a pre-risk state, and a staker calls withdraw() before anyone calls claimExpired().

Impact

Medium.

The same expired unresolved pool state can produce different bonus treatment depending on which route is called first.

A staker can exit with principal only, while the bonus remains outside that staker's settlement path and can later become recoverable. This creates a settlement-ordering inconsistency between post-expiry withdrawal and mechanical expiration resolution.

Proof of Concept

This PoC proves that after expiry, a staker can still use withdraw() before claimExpired() finalizes the pool. The staker receives principal only, is removed from accounting, and the bonus remains outside that staker's settlement path.

Steps to reproduce

  1. A staker deposits into the pool.

  2. A bonus is contributed.

  3. The pool reaches expiry.

  4. The registry remains in a pre-risk state.

  5. The staker calls withdraw() before claimExpired().

  6. The staker receives principal only.

  7. The staker is removed from accounting.

  8. The bonus remains in the pool and can later become recoverable.

Relevant code path

// @> Pool is expired but still unresolved.
block.timestamp >= expiry;
outcome == UNRESOLVED;
// @> Registry is still pre-risk.
registryState == NEW_DEPLOYMENT || registryState == ATTACK_REQUESTED;
// @> Staker can still exit through withdraw().
withdraw();
// @> Principal is paid, but bonus is not paid through this route.
eligibleStake[staker] = 0;
// @> Bonus can later be handled by the sweep path.
sweepUnclaimedBonus();

Test code

function testPostExpiryWithdrawInPreRiskStateForfeitsBonusAndLeavesBonusSweepable() public {
// staker deposits and bonus is contributed
// pool reaches expiry while registry is still pre-risk
vm.warp(pool.expiry());
uint256 beforeBalance = token.balanceOf(staker);
vm.prank(staker);
pool.withdraw();
uint256 afterBalance = token.balanceOf(staker);
// @> Staker receives principal only.
assertEq(afterBalance - beforeBalance, stakeAmount);
// @> Bonus remains outside that staker's settlement path.
pool.claimExpired();
pool.sweepUnclaimedBonus();
}

Command

forge test --match-path "test/internal/*" --match-test testPostExpiryWithdrawInPreRiskStateForfeitsBonusAndLeavesBonusSweepable -vvv

Observed output

[PASS] testPostExpiryWithdrawInPreRiskStateForfeitsBonusAndLeavesBonusSweepable()
Suite result: ok.

What the PoC proves

Before mechanical expiration resolution:

pool expired = true
outcome = UNRESOLVED
registry state = pre-risk
staker eligible stake > 0
bonus present in pool

After post-expiry withdraw:

staker receives principal only
staker eligible stake = 0
bonus remains outside that staker's settlement path

This proves that the post-expiry withdraw route can change the final accounting path before the pool is mechanically resolved.

Recommended Mitigation

The pool should preserve a consistent settlement boundary after expiry.

A safe fix should ensure that withdrawal and expiration-resolution routes cannot produce inconsistent accounting outcomes for the same expired unresolved state.

- Allow pre-risk withdraw logic to remain active after expiry before mechanical resolution.
+ Preserve a consistent post-expiry settlement boundary.
+ Ensure withdrawal and expiration resolution cannot produce divergent bonus/accounting outcomes.

A regression test should cover an expired unresolved pool where a staker attempts to withdraw before mechanical expiration resolution.

Support

FAQs

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

Give us feedback!