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

First-Staker Expiry Front-Run Can Strand Principal

Author Revealed upon completion

Root + Impact

Description

  • Before the first successful stake, the pool owner may update expiry; the first stake then permanently locks the current value.

  • stake() does not bind the user’s transaction to the expiry they observed. A sponsor can front-run a pending first stake with a far-future expiry, causing the user’s principal to remain locked long after the advertised deadline.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (block.timestamp >= expiry) revert StakingClosed();
if (!expiryLocked) {
// @> Locks whichever expiry is live at execution time
expiryLocked = true;
}
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
// @> No protection against a pending first-stake transaction
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
expiry = uint32(newExpiry);
}

Risk

Likelihood:

  • The pool owner changes expiry between a user signing and execution of the first stake() transaction.

  • The registry enters UNDER_ATTACK, disabling withdrawals before the user notices the changed deadline.

Impact:

  • withdraw() reverts after risk begins.

  • claimExpired() reverts at the originally advertised deadline, leaving principal locked until the replacement expiry, potentially decades later.

Proof of Concept

The PoC simulates a user signing a first stake while the pool shows a 31-day expiry. Before execution, the owner changes the
expiry to type(uint32).max. The stake then locks this new deadline; after risk begins, withdrawal is disabled, and claiming
at the original deadline reverts with PoolNotExpired, leaving the principal locked for decades.

uint256 oldExpiry = block.timestamp + 31 days;
// Alice signs a stake based on oldExpiry.
vm.prank(poolOwner);
pool.setExpiry(type(uint32).max);
// Alice's transaction executes against the changed expiry.
vm.prank(alice);
pool.stake(100e18);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(oldExpiry);
vm.prank(alice);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();

Recommended Mitigation

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

Alternatively, prevent setExpiry() once the registry leaves pre-attack staging.

Support

FAQs

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

Give us feedback!