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

Stake transactions can be reordered across the UNDER_ATTACK transition and involuntarily lock principal

Author Revealed upon completion

Root + Impact

Description

The protocol intentionally supports two materially different staking conditions:

  1. In a pre-risk state, the staker can deposit and later recover their principal through withdraw().

  2. In UNDER_ATTACK, the staker can deposit, but the deposit is immediately committed to settlement and cannot be withdrawn.

A staker should only enter the second condition when they explicitly agree to accept that additional risk.

However, stake() does not let the caller bind the transaction to the registry state they observed when signing it. The function accepts the transaction even if the registry changes from a withdrawable state to UNDER_ATTACK before execution.

The transaction first observes the current registry state:

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();
// @> UNDER_ATTACK is accepted even when the caller signed the
// @> transaction while the pool was still withdrawable.
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true;
}
...
}

_assertDepositsAllowed() deliberately does not reject 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();
}
// @> UNDER_ATTACK falls through and the deposit succeeds.
}

When the state is UNDER_ATTACK, _observePoolState() permanently opens the pool's risk window:

if (
riskWindowStart == 0
&& _isActiveRiskState(state)
) {
_markRiskWindowStart();
}

The deposit is then credited normally. Once it has succeeded, withdraw() checks the persistent risk-window latch and rejects the user even if the registry later returns to a pre-risk state:

if (
// @> The stake transaction that observed UNDER_ATTACK permanently
// @> prevents the caller from withdrawing.
riskWindowStart != 0
|| (
state
!= IAttackRegistry.ContractState.NOT_DEPLOYED
&& state
!= IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state
!= IAttackRegistry.ContractState.ATTACK_REQUESTED
)
) {
revert WithdrawsDisabled();
}

This creates the following transaction-ordering attack:

  1. Alice reads the registry in NEW_DEPLOYMENT or ATTACK_REQUESTED.

  2. Alice submits stake(), expecting that the withdrawal escape hatch remains available.

  3. The normal registry transaction that changes the state to UNDER_ATTACK is placed before Alice's transaction.

  4. Alice's transaction does not revert because staking during UNDER_ATTACK is allowed.

  5. The transaction opens riskWindowStart and transfers Alice's tokens into the pool.

  6. Alice can no longer withdraw.

  7. If the agreement is later marked CORRUPTED, Alice's entire principal can be paid to the attacker or swept to recoveryAddress.

This can occur through ordinary transaction ordering around the public transition to UNDER_ATTACK. The state-changing transaction does not need to be malicious. A sequencer or block builder only needs to place Alice's pending stake after it.

The design describes an UNDER_ATTACK deposit as visible and voluntary and states that a staker can inspect the live registry before depositing. An off-chain inspection is not atomic with transaction execution, so it does not protect against this ordering change. :contentReference[oaicite:0]{index=0}

Risk

A user who intended to enter while withdrawals were available can instead be committed to the full corruption-resolution path.

The consequences are:

  • the user's principal becomes non-withdrawable immediately;

  • the user may be locked until expiry or moderator resolution;

  • if the agreement becomes corrupted, the user's complete principal may be transferred to the named attacker or recoveryAddress;

  • the transaction succeeds rather than reverting, so the user receives no on-chain indication that the risk condition changed;

  • the attack requires no compromise of the pool contract.

The impact is High because the affected user's complete principal can be lost.

The likelihood is Medium because it applies to pending stake transactions around the normal transition from a pre-risk state to UNDER_ATTACK.

Proof of Concept

Add the following test to a contract inheriting BaseConfidencePoolTest:

function test_StakeReorderedAfterUnderAttackTransitionIsLocked()
external
{
uint256 amount = 100 * ONE;
token.mint(alice, amount);
vm.prank(alice);
token.approve(address(pool), amount);
// Alice observes a pre-risk state before submitting stake().
assertEq(
uint256(
attackRegistry.getAgreementState(agreement)
),
uint256(
IAttackRegistry.ContractState.NEW_DEPLOYMENT
)
);
// The registry transition is included before Alice's pending stake.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// Alice's transaction does not revert. It silently enters under
// the materially different, non-withdrawable risk condition.
vm.prank(alice);
pool.stake(amount);
assertEq(pool.eligibleStake(alice), amount);
assertGt(pool.riskWindowStart(), 0);
// The principal is now irreversibly locked into settlement.
vm.prank(alice);
vm.expectRevert(
IConfidencePool.WithdrawsDisabled.selector
);
pool.withdraw();
// The agreement is subsequently corrupted.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
false,
address(0)
);
uint256 recoveryBefore =
token.balanceOf(recovery);
pool.claimCorrupted();
// Alice loses the complete principal even though she submitted
// the stake while observing a withdrawable state.
assertEq(
token.balanceOf(recovery) - recoveryBefore,
amount
);
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(address(pool)), 0);
}

Recommended Mitigation

Allow callers to bind a stake transaction to an acceptable risk condition.

One option is to add an expected-state parameter:

function stake(
uint256 amount,
IAttackRegistry.ContractState expectedState
) external nonReentrant whenPoolNotPaused {
IAttackRegistry.ContractState state =
_observePoolState();
if (state != expectedState) {
revert RegistryStateChanged();
}
_assertDepositsAllowed(state);
...
}

A more flexible interface can let callers specify whether they permit entering after the risk window opens:

function stake(
uint256 amount,
bool acceptActiveRisk
) external nonReentrant whenPoolNotPaused {
IAttackRegistry.ContractState state =
_observePoolState();
if (
!acceptActiveRisk
&& riskWindowStart != 0
) {
revert ActiveRiskNotAccepted();
}
_assertDepositsAllowed(state);
...
}

The check must occur inside stake() before transferring tokens. An off-chain registry read or frontend warning is insufficient because the state can change before transaction inclusion.

The same protection should be considered for integrations that submit stake transactions through relayers or delayed execution systems.

Support

FAQs

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

Give us feedback!