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

expiry has no sane upper bound and no stake-time guard, letting a sponsor front-run the first stake to freeze staker capital for decades

Author Revealed upon completion

Root + Impact

Description

Normal behavior: expiry is the pool's deadline: after it, the permissionless claimExpired backstop lets stakers recover principal, and the fabricated-/real-CORRUPTED freeze resolves at expiry + MODERATOR_CORRUPTED_GRACE. The design locks expiry at the first stake (expiryLocked) with the stated intent, per DESIGN.md, of protecting staker reliance — a staker should be able to trust the expiry they see when they deposit.

The issue: That protection is incomplete in two compounding ways. First, expiry has no sane upper bound: setExpiry/createPool only reject values above type(uint32).max (~year 2106), so the sponsor can set the deadline ~80 years out. Second, stake accepts only amount — it has no "expected/maximum expiry" parameter and only checks that expiry has not already elapsed — so a staker's transaction commits to whatever expiry is in storage at execution time. Because expiry is mutable right up until the first stake latches expiryLocked, a sponsor can watch the mempool and front-run the first stake with setExpiry(type(uint32).max); the victim's stake then lands and permanently locks the extreme value, with no lever the staker could have used to reject it. This stretches the maximum no-exit / no-claim freeze window from months to decades and amplifies every sponsor-driven freeze path (fabricated CORRUPTED, goToProduction premium denial).

// src/ConfidencePool.sol
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon(); // lower bound only
// @> No upper-lead bound: the only ceiling is ~year 2106. The sponsor can push the pool's
// @> deadline (and thus the max no-exit/no-claim freeze = expiry + 180d) ~80 years out.
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
expiry = uint32(newExpiry);
...
}
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (block.timestamp >= expiry) revert StakingClosed();
// @> `stake` takes no expected-expiry argument, so the staker cannot bound the expiry they
// @> accept. A sponsor front-run of setExpiry lands silently; this stake then locks it forever.
...
if (!expiryLocked) { expiryLocked = true; }
...
}

Risk

Likelihood:

  1. The pool owner is the sponsor and holds setExpiry; the value is mutable up to the exact moment the first stake latches expiryLocked, so the front-run window is open on every fresh pool.

  2. A staker's stake(amount) transaction carries no expiry expectation, so when the sponsor front-runs setExpiry, the victim's transaction cannot revert on the changed value — it silently locks the extreme expiry.

  3. Even absent a front-run, a malicious sponsor sets an unreasonable expiry at createPool; stakers who do not scrutinize the raw value inherit it.

Impact:

  1. The maximum window during which a staker has no exit and no claim — expiry + MODERATOR_CORRUPTED_GRACE — is stretched from months (e.g. 211 days at a 31-day expiry) to 80 years.

  2. Under any sponsor-driven freeze (fabricated CORRUPTED, or simply an agreement that never resolves), the staker cannot withdraw (risk window sealed), cannot claimSurvived (no flag), and cannot claimExpired until the locked expiry passes. Their capital is frozen at zero yield for the full stretched window.

  3. It is a force-multiplier on the fabricated-CORRUPTED principal-sweep finding: the same attack, with a front-run expiry, holds stakers hostage for decades before the sweep, rather than ~6 months.

  4. Principal is ultimately returned, so this is capital lock / opportunity-cost griefing, not theft — hence Low severity despite the extreme duration.

Proof of Concept

Verified, passing (mock-based; runs with a plain forge test).

// 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";
/// @notice PoC: `expiry` has no sane upper bound (only `type(uint32).max`, ~year 2106) and `stake`
/// carries no expected-expiry guard, so a sponsor can front-run the first stake with
/// `setExpiry(uint32.max)`. The staker's stake then locks the extreme value, stretching the maximum
/// no-exit / no-claim freeze window (`expiry + MODERATOR_CORRUPTED_GRACE`) from months to ~80 years
/// and amplifying the fabricated-CORRUPTED freeze. Principal is not lost, so this is a Low-severity
/// griefing / capital-lock amplifier.
contract PoC_UnboundedExpiry is BaseConfidencePoolTest {
function test_PoC_frontrunExpiry_locksStakerForDecades() public {
// The pool was advertised with a reasonable ~31-day expiry (set in setUp). A staker sees
// this and prepares to deposit. With that expiry, the worst-case freeze under the
// fabricated-CORRUPTED path would be ~31d + 180d ≈ 211 days.
uint256 advertisedExpiry = pool.expiry();
uint256 reasonableFreeze = advertisedExpiry + pool.MODERATOR_CORRUPTED_GRACE();
assertLt(reasonableFreeze, block.timestamp + 365 days, "advertised freeze < 1 year");
// FRONT-RUN: before any stake locks it, the sponsor (owner) pushes expiry to the maximum.
// Passes both bounds: > now + 30d, and <= type(uint32).max. No upper-lead cap exists.
uint256 maxExpiry = type(uint32).max; // ~ Feb 2106
pool.setExpiry(maxExpiry);
// The staker's stake carries ONLY `amount` — no expected-expiry parameter — so it cannot
// reject the changed value. It succeeds and permanently locks the extreme expiry.
_stake(alice, 100 * ONE);
assertEq(pool.expiry(), maxExpiry, "staker silently locked into a ~2106 expiry");
// expiry is now frozen; even the sponsor cannot revert it (damage already done).
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(advertisedExpiry);
// The maximum freeze window is now ~80 years instead of ~211 days.
uint256 extremeFreeze = uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE();
assertGt(extremeFreeze, block.timestamp + 79 * 365 days, "freeze window stretched to decades");
// --- Demonstrate the staker is actually frozen that long (fabricated-CORRUPTED path) ---
// Agreement goes attackable; the window is observed -> staker can no longer withdraw.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// Sponsor fabricates CORRUPTED (onlyAttackModerator, no breach).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
// Fifty years later the staker STILL cannot recover: block.timestamp < expiry (~2106),
// so claimExpired reverts, withdraw is disabled, and no SURVIVED flag exists.
vm.warp(block.timestamp + 50 * 365 days);
assertLt(block.timestamp, uint256(pool.expiry()), "still before the locked expiry");
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// Only at expiry + grace (~2106) can the pool finally resolve — the staker's capital was
// frozen for ~80 years by a single front-run the staker had no way to guard against.
}
}

Recommended Mitigation

Add a sane upper-lead bound in both setExpiry and createPool/initialize, so the worst-case freeze is proportionate to a testnet agreement's real lifecycle:

if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
- if (newExpiry > type(uint32).max) revert ExpiryTooFar();
+ if (newExpiry > block.timestamp + _MAX_EXPIRY_LEAD) revert ExpiryTooFar(); // e.g. 365 days

To eliminate the front-run entirely (not just bound its damage), also let the staker commit to a ceiling:

- function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
+ function stake(uint256 amount, uint256 maxExpiry) external nonReentrant whenPoolNotPaused {
+ if (expiry > maxExpiry) revert ExpiryExceedsExpected();
if (block.timestamp >= expiry) revert StakingClosed();
...
}

Support

FAQs

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

Give us feedback!