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

expiryLocked never resets on full withdrawal, allowing zero-cost griefing of the sponsor's setExpiry

Author Revealed upon completion

Root + Impact

Description

  • expiryLocked is set permanently on the first stake() call and never reset, including when that staker fully withdraws via withdraw(). This means any address, with no genuine staking intent, can call stake(minStake) then immediately withdraw(), permanently locking expiry while recovering their full deposit — denying the sponsor any future correction to a misconfigured deadline, even on a pool where zero real capital is currently at risk.


// Root cause in the codebase with @> marks to highlight the relevant section
// inside stake():
@> if (!expiryLocked) { expiryLocked = true; }
function withdraw() external nonReentrant
{
...
totalEligibleStake -= amount;
@> // no reference to expiryLocked anywhere here, even when this call
@> // returns totalEligibleStake to zero
stakeToken.safeTransfer(msg.sender, amount);
}

Risk

Likelihood:

  • Occurs whenever any address stakes minStake and withdraws it in quick succession on a freshly deployed pool — no privilege, no net capital, no coordination required.

Impact:

  • Sponsor permanently loses setExpiry, forcing full pool redeployment as the only remedy, while the attacker's net token cost is exactly zero.


Proof of Concept

The PoC stakes a griefer address for the minimum amount, withdraws immediately, and confirms the griefer's token balance is unchanged before and after — proving zero net cost — while pool.expiryLocked() is now true and the sponsor's subsequent setExpiry call reverts.

function test_PoC_UnrelatedThirdPartyLocksExpiryConfigurationForFree() external
{
address griefer = makeAddr("griefer");
uint256 griefBalanceBefore = token.balanceOf(griefer);
_stake(griefer, ONE);
vm.prank(griefer);
pool.withdraw();
assertEq(token.balanceOf(griefer), griefBalanceBefore, "zero net cost");
assertEq(pool.expiryLocked(), true);
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 60 days);
}

Recommended Mitigation

Reset the lock only when totalEligibleStake returns to exactly zero, preserving the original protection (no sponsor movement while genuine stake is outstanding) while closing the zero-stake griefing path.

function withdraw() external nonReentrant
{ ...
totalEligibleStake -= amount;
+ if (totalEligibleStake == 0) {
+ expiryLocked = false;
+ }
stakeToken.safeTransfer(msg.sender, amount);
}

Support

FAQs

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

Give us feedback!