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

Transient first stake permanently revokes the sponsor's ability to update pool expiry

Author Revealed upon completion

Any user can permanently lock expiry, permanently preventing the sponsor from using setExpiry() with only a temporary minimum stake

Description

Inside ConfidencePool.sol, the pool allows the owner (pool creator) to modify the expiry through setExpiry() function until expiryLocked becomes true.

And the only place where this expiryLocked is set to be true is inside stake function:

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
@> if (!expiryLocked) {
@> expiryLocked = true;
@> }
...
}

If we check setExpiry, then:

function setExpiry(uint256 newExpiry) external onlyOwner {
@> if (expiryLocked) revert ExpiryLocked();
...
}

The docs/DESIGN.md mentions:

expiry — sponsor-mutable only until the first stake (one-way expiryLocked latch). This protects staker reliance: once anyone has deposited against a given deadline (which feeds the k=2 weighting as T for the EXPIRED path), the sponsor cannot move it

They are aware of the fact that it should be locked right after the first stake, which makes sense. Soon they said, "This protects staker reliance". Yes, I agree. But what if the latch could simply be triggered, and the people whose reliance is supposedly being protected may not even exist yet?

Let's consider the following case:

  1. Pool gets deployed

  2. A malicious staker stakes the minStake amount immediately, triggering expiryLocked = true

  3. Then he withdraws it right away

The expiry lock now outlives the very stake that justified creating it.

The protocol intentionally provides setExpiry() for a reason, because it supports updating the expiry before it becomes immutable. However, due to the current implementation, this configuration window can be reduced to a single temporary minimum stake by an arbitrary third party.

As a result, the pool creator permanently loses the administrative ability to call setExpiry(), even though the very stake that triggered the lock may no longer exist.

There could be several legitimate reasons why a creator would be willing to extend the expiry before the pool configuration becomes final:

  • operational delay

  • governance delay

  • deployment coordination

  • launch postponement

  • ...

The point is, setExpiry exists for a reason, not to just simply get bricked right after the pool deployment. Additionally, locking up the expiry in a pre-risk state will always go in the stakers' favour, as this will lead to less time for the whitehats to attack, in case the protocol does want to extend the expiry.

Risk

Likelihood: Medium

  • Any user can trigger the lock permissionlessly by staking the minimum amount immediately after pool deployment.

  • The attacker can immediately recover their funds through withdraw() while the pool is still in a pre-risk state, making the attack inexpensive (primarily gas cost)

Impact: Medium

  • The sponsor permanently loses the administrative ability to update the pool expiry, even when no stakers remain in the pool, almost for free.

  • Operational changes that legitimately require extending the expiry before the pool enters its active underwriting period can no longer be performed, forcing the sponsor to continue with an unintended expiry or abandon the existing pool and migrate participants and bonus liquidity to a replacement pool.

Proof of Concept

Although I didn't feel like it needed one, we can still check it out by adding the following testSetExpiryGettingBrickedWithNoStakersPresent() function inside test/unit/ConfidencePool.t.sol:

function testSetExpiryGettingBrickedWithNoStakersPresent() external {
// Pool already deployed
// Sponsor contributes bonus to show skin in the game
_contributeBonus(carol, 40 * ONE);
// Malicious user temporarily stakes
_stake(alice, ONE); // ONE = 1e18 is the minimum stake for this pool
// Immediately withdraws
vm.prank(alice);
pool.withdraw();
console.log("Expiry Locked?", pool.expiryLocked());
console.log("Total Eligible Stake:", pool.totalEligibleStake());
// Time passes before the sponsor decides to extend the expiry
vm.warp(vm.getBlockTimestamp() + 10 days);
// Tries to extend the expiry but it reverts
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
vm.prank(address(this)); // `address(this)` is the owner here
pool.setExpiry(vm.getBlockTimestamp() + 30 days);
}

Execution demonstrates:

Ran 1 test for test/unit/ConfidencePool.t.sol:ConfidencePoolTest
[PASS] testSetExpiryGettingBrickedWithNoStakersPresent() (gas: 363546)
Logs:
Expiry Locked? true
Total Eligible Stake: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.67ms (223.92µs CPU time)

The owner has been completely stripped of the setExpiry capability with a simple temporary minimum stake, even though there are no remaining stakers in the pool.

Recommended Mitigation

Like the docs/DESIGN.md mentioned in the same point:

The latch intentionally does not reset when stake is withdrawn — resetting it would let the sponsor move expiry during an all-stakers-exited moment and harm the next cohort.

This is obviously true and should be preserved.

The actual issue is that the expiry becomes immutable while the pool is still in a completely risk-free state, allowing an arbitrary third party whose stake can immediately be withdrawn to permanently revoke an administrative capability from the sponsor.

Instead, the expiry should only become immutable once participants become economically committed in a way that cannot be trivially reverted — for example, once the pool enters the active risk window, where withdrawals are already disabled.

Therefore, we can definitely move this code block (the one setting up the expiryLocked to be true) to _markRiskWindowStart as withdrawals are already disabled from that point onward.

function stake(uint256 amount) external {
...
- if (!expiryLocked) {
- expiryLocked = true;
- }
...
}
function _markRiskWindowStart() internal {
+ if (!expiryLocked) {
+ expiryLocked = true;
+ }
...
}

Support

FAQs

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

Give us feedback!