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

A permissionless dust stake-then-withdraw permanently latches expiryLocked, blocking the sponsor from ever correcting expiry even when the pool has zero stakers

Author Revealed upon completion

Root + Impact

Description

  • expiry is sponsor-mutable via setExpiry() only until the first stake: the first stake() sets the one-way expiryLocked latch to protect staker reliance on the deadline that feeds the k=2 weighting. Per docs/DESIGN.md §10 the latch intentionally does not reset on withdraw, to stop the sponsor from moving expiry during an all-stakers-exited moment and harming the next cohort.

  • Because the latch is set by any stake and never resets, a permissionless actor can stake the minStake dust and immediately withdraw() — leaving the pool with zero eligible stake but expiryLocked == true forever. The sponsor can then never call setExpiry() (it reverts ExpiryLocked) even though no staker relies on the deadline, so a misconfigured expiry can never be corrected on that pool.

// stake() — the FIRST stake (even dust) permanently latches expiryLocked
@> if (!expiryLocked) {
@> expiryLocked = true;
}
// withdraw() — zeroes the caller's eligibleStake/sums but does NOT reset expiryLocked
// setExpiry() — permanently blocked once latched, regardless of current stake
function setExpiry(uint256 newExpiry) external onlyOwner {
@> if (expiryLocked) revert ExpiryLocked(); // stays true even at totalEligibleStake == 0
...
}

Risk

Likelihood:

  • Immediately after pool creation, a permissionless griefer stakes minStake and withdraws it in the same or a later block, latching expiryLocked while leaving totalEligibleStake == 0.

  • The sponsor later needs to correct a mistyped/suboptimal expiry (the reason setExpiry exists pre-lock) and finds it permanently blocked.

Impact:

  • No funds are lost. The sponsor's setExpiry capability is permanently griefed on that pool even with zero stakers relying on the deadline; the sponsor's only recourse is to abandon the pool and deploy a fresh one.

Proof of Concept

function test_F2_dustStakeWithdraw_locksExpiryForever() external {
// griefer latches the lock with dust, then exits
token.mint(griefer, minStake);
vm.startPrank(griefer);
token.approve(address(pool), minStake);
pool.stake(minStake);
pool.withdraw(); // pool now has ZERO eligible stake
vm.stopPrank();
assertEq(pool.totalEligibleStake(), 0);
// sponsor can no longer correct expiry despite no stakers relying on it
vm.prank(sponsor);
vm.expectRevert(ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 90 days);
}

Recommended Mitigation

Reset the latch when the pool returns to zero eligible stake (no staker relies on the deadline), or gate setExpiry on totalEligibleStake == 0 instead of on a permanent latch — this preserves §10's protection while no cohort exists.

function setExpiry(uint256 newExpiry) external onlyOwner {
- if (expiryLocked) revert ExpiryLocked();
+ // reliance only exists while stakers are actually present; allow correction when none are.
+ if (expiryLocked && totalEligibleStake != 0) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
...
}

Support

FAQs

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

Give us feedback!