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

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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

Give us feedback!