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

An accidental expiry-update race can lock the first staker into an unintended term

Author Revealed upon completion

Root + Impact

Description

The sponsor is allowed to change the pool expiry until the first stake is accepted. The first successful stake then locks the expiry permanently.

The sponsor and the staker may act independently. For example, the sponsor may submit a legitimate expiry extension without knowing that the first stake is already pending. Currently, stake() accepts only an amount, so the staker cannot require the expiry to remain equal to the value they reviewed.

Both transactions remain valid when they are included in the same block and the expiry update executes first:

// src/ConfidencePool.sol
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
uint256 oldExpiry = expiry;
expiry = uint32(newExpiry); // @> The expiry changes while the first stake is pending
emit ExpiryUpdated(oldExpiry, newExpiry);
}

The pending stake still succeeds because it checks only the new live expiry. It then locks that new value:

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true; // @> The stake accepts and locks the sponsor's new expiry
}
// ...
}

For example, Alice sees a pool that expires in 31 days and submits the first stake. At around the same time, the sponsor independently submits setExpiry(block.timestamp + 365 days). Both transactions are included in the same block, and normal transaction ordering happens to execute the expiry update first. Alice's stake still succeeds and permanently locks the one-year expiry even though she submitted it based on the original 31-day term.

This does not require front-running or malicious transaction ordering. It is a Time-of-check to time-of-use race condition between two valid actions that were prepared using different pool terms.

The result is worse when the Agreement is already UNDER_ATTACK. Alice's stake observes the active risk state and starts the risk window. Withdrawals are then disabled immediately, so Alice cannot exit after noticing the changed expiry. At the original 31-day deadline, claimExpired() also reverts because the pool now expires after one year.

Risk

Likelihood:

  • The first stake must be pending at the same time as a legitimate sponsor expiry update.

  • Normal block ordering must execute the expiry update before the stake.

  • Immediate loss of the withdrawal path occurs when the Agreement is already UNDER_ATTACK.

Impact:

  • The staker accepts a materially different term from the one they reviewed.

  • The stake can remain locked until the sponsor-selected expiry.

  • No tokens are stolen, and the principal remains claimable after the new expiry, so the impact is Low.

Proof of Concept

Add the following test to test/unit/StakeExpirySlippage.t.sol and run:

forge test --match-contract StakeExpirySlippageTest -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract StakeExpirySlippageTest is BaseConfidencePoolTest {
function testPoC_sameBlockExpiryRaceLocksFirstStakeIntoExtendedTerm() external {
uint256 originalExpiry = pool.expiry();
uint256 extendedExpiry = block.timestamp + 365 days;
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// The sponsor and Alice submit independent transactions based on different assumptions.
// Normal same-block ordering happens to execute the expiry update first.
pool.setExpiry(extendedExpiry);
_stake(alice, 10 * ONE);
assertEq(pool.expiry(), extendedExpiry, "first stake accepted the sponsor-updated expiry");
assertEq(pool.expiryLocked(), true, "first stake should lock the updated expiry");
assertEq(pool.riskWindowStart(), block.timestamp, "staking during UNDER_ATTACK opens risk immediately");
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(originalExpiry + 1);
vm.prank(alice);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
}
}

The test passes and shows that Alice cannot withdraw and cannot claim at the expiry she originally observed.

Recommended Mitigation

Let every staker bind the transaction to an acceptable expiry. The simplest option is an exact check:

function stake(uint256 amount, uint256 expectedExpiry) external {
if (expiry != expectedExpiry) revert ExpiryChanged();
// Existing stake logic
}

A maxExpiry parameter can be used instead when the protocol wants to allow small changes:

if (expiry > maxExpiry) revert ExpirySlippageExceeded();

This keeps the sponsor's existing ability to update the pool before the first stake while allowing the staker to reject an unexpected term.- remove this code
+ add this code

Support

FAQs

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

Give us feedback!