FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

stake() ignores the riskWindowStart latch withdraw() enforces, letting a staker deposit into an already risk-sealed pool that still displays pre-attack state, with no informed consent

Impact - Low The depositor loses the exit the moment they enter, and if the agreement later settles CORRUPTED their principal is swept along with everyone else's. What they read beforehand said the opposite: DESIGN.md #3 tells stakers to check the live registry state, and after a rewind that state reports pre-attack while the latch behind it is already sealed. They still earn an ordinary late-entrant bonus, so what they lose is the exit and the exposure that comes with it. Likelihood - Low Reaching the split needs the attack-registry pointer migrated while a pool is mid-risk, in the window before the fresh registry has re-registered the agreement. Nothing else is privileged, and once the live state reads pre-attack any ordinary depositor walks in through public calls. Cutting the other way, riskWindowStart is a public variable, so anyone who checks the pool instead of the registry is safe.

Support

FAQs

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

Give us feedback!