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

Sponsor can front-run the first staker's stake() with setExpiry() to trap their principal until an arbitrary far-future expiry

Author Revealed upon completion

Root + Impact

ConfidencePool.setExpiry() lets the pool sponsor change expiry freely until the very first stake(), and stake() accepts no expected-expiry parameter. A sponsor can therefore front-run a staker's stake() and move expiry to an arbitrarily far future in the same block. The staker's deposit locks that value in permanently (expiryLocked), and once the risk window opens withdraw() is disabled — trapping their principal until the manipulated (potentially ~year-2106) expiry.

Description

  • Normally a staker reads the pool's expiry (the term they underwrite), decides it is acceptable, and stakes. On the EXPIRED backstop path their principal is returned once block.timestamp >= expiry.

  • The sponsor controls expiry via setExpiry() while !expiryLocked, and expiryLocked only flips to true on the first stake() (contributeBonus does NOT flip it). stake() has no expectedExpiry argument, so a staker cannot make their deposit conditional on the term they saw. A sponsor observing a pending stake() front-runs it with setExpiry(farFuture); the staker's tx then mines against the manipulated expiry and permanently locks it in.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
@> if (!expiryLocked) { // only the FIRST stake locks expiry
@> expiryLocked = true;
}
// ... no expectedExpiry check — staker cannot bind the term they saw
}
function setExpiry(uint256 newExpiry) external onlyOwner {
@> if (expiryLocked) revert ExpiryLocked(); // freely mutable until first stake
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
expiry = uint32(newExpiry);
}

Risk

Likelihood:

  • The sponsor holds other stakers' capital and gains by extending the lock-up beyond the agreed term; front-running is free and deterministic on a public mempool, so a sponsor simply watches for the first stake() into a pool they own.

  • Applies to the first staker of every pool, since expiryLocked is false until that exact deposit lands.

Impact:

  • The first staker's principal is locked until the manipulated expiry (up to type(uint32).max ~ year 2106) once the risk window opens and withdraw() is disabled. On the EXPIRED path this is an ~80-year lock — economically equivalent to a loss of the funds (time value ~ 100%).

  • Earlier recovery depends on a moderator resolving the pool (SURVIVED / CORRUPTED), which is not guaranteed and is outside the staker's control.

Proof of Concept

test/fuzz/ExpiryFrontRunPoC.t.sol — passes:

function test_sponsorFrontRunsExpiry_trapsFirstStaker() external {
uint256 termAliceExpects = pool.expiry(); // pool created with ~31-day term
// Sponsor front-runs Alice's stake in the same block:
uint256 farFuture = uint256(type(uint32).max); // ~year 2106
pool.setExpiry(farFuture);
_stake(alice, 100 ether); // Alice's stake locks the manipulated expiry
assertTrue(pool.expiryLocked());
// Risk materializes -> withdraw permanently disabled:
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// Even 10 years later, claimExpired cannot resolve — funds trapped until ~2106:
vm.warp(vm.getBlockTimestamp() + 3650 days);
vm.prank(alice);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
}

Recommended Mitigation

Bind the staker's deposit to the term they observed by adding an expectedExpiry (slippage-style) parameter to stake():

- function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+ function stake(uint256 amount, uint256 expectedExpiry) external nonReentrant whenPoolNotPaused {
+ if (expiry != expectedExpiry) revert ExpiryChanged();
if (amount == 0) revert InvalidAmount();

Alternatively (or in addition), lock expiry on the first value-bearing interaction of any kind — including contributeBonus — so a pool that has attracted any capital can no longer have its term moved.

Support

FAQs

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

Give us feedback!