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

stake() is gated only on live registry state, not the riskWindowStart latch that withdraw() enforces: after a rewind a new staker enters an 'exit-open' pool and is immediately trapped

Author Revealed upon completion

Root + Impact

src/ConfidencePool.sol — the deposit gate and the withdraw gate are asymmetric with respect to the one-way riskWindowStart latch.

  • withdraw() is disabled whenever riskWindowStart != 0 (the "risk has materialized" latch), specifically so a benign upstream registry rewind cannot re-open exits (DESIGN.md §11).

  • stake() (_assertDepositsAllowed) gates ONLY on the live registry state — it never checks riskWindowStart. It stays OPEN in NOT_DEPLOYED / NEW_DEPLOYMENT / ATTACK_REQUESTED even after the latch has sealed.

After the registry rewinds from an active-risk state back to a pre-attack state, the pool advertises NEW_DEPLOYMENT (deposits open, apparently pre-attack) while withdraw() is already permanently disabled. A new staker who reads the live registry state — exactly what DESIGN.md §3 instructs ("A staker who does not want this can read the live registry state before staking") — deposits believing exit is open, and is immediately trapped in the resolution path with no exit.

Impact: the staker loses the exit option the instant they deposit, without risk having visibly materialized in the state they observed. If the agreement later resolves CORRUPTED in-scope, their principal is swept to recoveryAddress. The §11 anti-rewind protection is incomplete: it blocks the exit but not the entrance.

Description

withdraw gate — enforces the latch:

if (
riskWindowStart != 0
|| (state != NOT_DEPLOYED && state != NEW_DEPLOYMENT && state != ATTACK_REQUESTED)
) revert WithdrawsDisabled();

deposit gate — ignores the latch:

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

@> _assertDepositsAllowed receives only the live state; it has no access to and never checks riskWindowStart. NEW_DEPLOYMENT passes even when the latch is already sealed.

Sequence:

  1. Registry reaches UNDER_ATTACK; any interaction (or pokeRiskWindow) seals riskWindowStart != 0.

  2. Registry rewinds to NEW_DEPLOYMENT (§11 acknowledges benign rewinds happen).

  3. Bob reads the live state — NEW_DEPLOYMENT, "pre-attack, exit open" per §3 — and stakes. _assertDepositsAllowed(NEW_DEPLOYMENT) passes.

  4. Bob calls withdraw() → reverts WithdrawsDisabled (gated on the sealed latch). Bob is trapped.

This is distinct from §3 (intentional UNDER_ATTACK deposits): there the live state Bob reads IS UNDER_ATTACK, so his self-lock is informed and consensual. Here the live state reads pre-attack, so the lock is invisible at deposit time.

Risk

Likelihood: Low — requires a registry rewind from an active-risk state back to a pre-attack state.

Impact: Medium — a staker is trapped into the resolution path against a state that advertises exit is open; principal is at risk (CORRUPTED sweep) with no exit.

Proof of Concept

forge test --match-contract StakeTrappedAfterRewindTest -vv passes against the in-scope contracts:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract StakeTrappedAfterRewindTest is BaseConfidencePoolTest {
function test_newStakerTrappedImmediately_afterRegistryRewind() external {
// First cohort stakes; the risk window opens & seals.
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.riskWindowStart() != 0, "risk window sealed");
// Benign upstream rewind back to a pre-attack state.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
// Bob reads the live state (NEW_DEPLOYMENT = pre-attack, per §3) and stakes. ACCEPTED.
_stake(bob, 100 * ONE);
assertEq(pool.eligibleStake(bob), 100 * ONE, "stake accepted at NEW_DEPLOYMENT post-rewind");
// But Bob is already trapped: withdraw is gated on the sealed latch, not the live state.
vm.prank(bob);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
}
}

Recommended Mitigation

Make the deposit gate consistent with the withdraw latch WITHOUT breaking the intentional UNDER_ATTACK deposits of §3: reject stake() / contributeBonus() when riskWindowStart != 0 AND the live state is a pre-attack state (NOT_DEPLOYED / NEW_DEPLOYMENT / ATTACK_REQUESTED) — i.e. a detected rewind whose advertised state no longer matches the sealed latch. Deposits during a genuine UNDER_ATTACK (where the live state honestly reflects the risk) stay allowed. Concretely, pass riskWindowStart into _assertDepositsAllowed and add: if the latch is sealed but the live state reads pre-attack, revert StakingClosed.

Support

FAQs

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

Give us feedback!