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

A stake+withdraw round-trip permanently latches expiryLocked, letting an unrelated griefer foreclose the sponsor's pre-stake right to change expiry at near-zero cost

Author Revealed upon completion

Root + Impact

stake() sets the one-way expiryLocked flag on the first deposit and withdraw() never resets it, so a griefer who stakes minStake and immediately withdraws permanently blocks the sponsor's pre-stake right to call setExpiry.

Description

  • The sponsor may change expiry only until the first stake; the expiryLocked latch enforces this so genuine stakers can rely on the deadline they signed up for (DESIGN §10).

  • expiryLocked is latched by stake() and, by design, is never reset by withdraw(). But withdraw() is permitted for a full refund in any pre-attack state, so an unrelated party can stake exactly minStake, withdraw it next block, and leave the latch stuck "on" with no genuine staker relying on the deadline — permanently foreclosing the sponsor's legitimate pre-stake setExpiry.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
@> if (!expiryLocked) { expiryLocked = true; } // latched on ANY first deposit
...
}
function withdraw() external nonReentrant {
...
@> // full refund in pre-attack states, but expiryLocked is deliberately NOT reset
stakeToken.safeTransfer(msg.sender, amount);
}
function setExpiry(uint256 newExpiry) external onlyOwner {
@> if (expiryLocked) revert ExpiryLocked(); // now permanently blocked
...
}

Risk

Likelihood:

  • Triggers whenever a sponsor deploys with a placeholder expiry (e.g. the 30-day minimum) intending to extend it before opening the pool, and a griefer front-runs that adjustment.

  • Realized by a single stake-then-withdraw round-trip while the registry is in any pre-attack state; the griefer is fully refunded, so the only cost is gas.

Impact:

  • The sponsor can never change expiry again, even though the sole depositor has exited and no genuine staker relies on the deadline.

  • Bounded to griefing: no funds are lost and the sponsor can redeploy a fresh pool.

Proof of Concept

The baseline confirms the owner can move expiry before any stake; the grief test shows a fully-refunded stake+withdraw round-trip latches expiryLocked for good, after which setExpiry reverts ExpiryLocked.

// test/poc/ExpiryLockGrief.t.sol
function testGrieferFreezesExpiryWithStakeWithdrawRoundTrip() external {
address mallory = makeAddr("mallory");
_stake(mallory, ONE); // ONE == minStake; latches expiryLocked
assertTrue(pool.expiryLocked());
_withdraw(mallory);
assertEq(token.balanceOf(mallory), ONE); // griefer fully refunded, holds nothing
assertTrue(pool.expiryLocked()); // latch survives the withdraw
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 180 days);
}

Recommended Mitigation

Reset the latch when the pool empties, so a fully-exited pool restores the sponsor's pre-stake flexibility (a genuine reliant cohort keeps it locked):

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

(Note: DESIGN §10 argues the latch should not reset to protect a next cohort; if that intent is firm, the alternative is to accept this as known behavior. The reset above only re-opens setExpiry when there is provably no reliant staker.)

Support

FAQs

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

Give us feedback!