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

`riskWindowStar` latch silently rolls back on reverted `withdraw()`, breaking the documented one-way withdrawal lock

Author Revealed upon completion

Reverting withdraw() rolls back the active-risk latch, allowing withdrawals to reopen after registry migration

Description

ConfidencePool is designed to permanently disable withdrawals once the associated agreement reaches an active-risk state. To enforce this, _observePoolState() records the first observation of UNDER_ATTACK or PROMOTION_REQUESTED by setting riskWindowStart, which acts as a one-way historical latch. Once this latch is set, withdraw() must remain unavailable regardless of subsequent changes to the live registry state.

However, withdraw() calls _observePoolState() before checking whether withdrawals are disabled. When the agreement is in an active-risk state, _observePoolState() sets riskWindowStart, after which withdraw() reverts with WithdrawsDisabled(). Because both operations occur within the same transaction, the revert rolls back the riskWindowStart update. As a result, the pool can observe an active-risk state without permanently recording that observation, leaving the withdrawal lock dependent on the current registry state rather than the intended one-way historical latch.

function withdraw() external nonReentrant {
.
.
IAttackRegistry.ContractState state = _observePoolState();
// @> Observing an active-risk state sets riskWindowStart.
if (
riskWindowStart ! = 0
|| (state ! = IAttackRegistery.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED
)
) {
// @> The revert rolls back the riskWindowStart update made above,
// so the active-risk observation is not persisted.
revert WithdrawsDisabled();
}

Risk

Likelihood:

  • This occurs when the pool first observes an active-risk state through a withdraw() call, causing the riskWindowStart update to be rolled back together with the expected WithdrawsDisabled() revert.

  • The issue becomes exploitable when a legitimate attack-registry migration subsequently causes the pool to observe a withdrawal-enabled state before any successful transaction has persisted the risk-window latch.

Impact:

  • A staker can withdraw their full principal after the pool has already been exposed to an active-risk state, violating the protocol's permanent withdrawal-lock guarantee.

  • The withdrawing staker avoids the potential loss associated with a subsequent CORRUPTED outcome, shifting a greater share of the economic exposure onto the remaining stakers.

Proof of Concept

The following test demonstrates the issue entirely through the in-scope ConfidencePool behavior.

The AttackRegistry is not assumed to be vulnerable or malicious. The test uses the existing mock only to reproduce two external conditions that the ConfidencePool is explicitly designed to handle:

  1. The agreement is reported as UNDER_ATTACK, causing withdraw() to observe active risk and attempt to initialize riskWindowStart.

  2. The Safe Harbor registry later points to a new attack-registry instance, representing a legitimate registry migration. The pool intentionally reads this pointer live rather than caching the previous registry instance.

The registry migration used in this PoC does not assume a vulnerability or malicious behavior in the out-of-scope dependency. DESIGN.md explicitly anticipates legitimate DAO registry migrations, noting that pinning the attack-registry instance would “brick every existing pool on a legitimate DAO registry migration.” The migration therefore serves only as a documented operating condition that exposes the impact of the rolled-back riskWindowStart latch in the in-scope ConfidencePool.

Add the following test to the test suite and run:

forge test --match-test testRiskWindowLatchNotPersistedOnRevert -vvvv

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockAttackRegistry} from "./mocks/MockAttackRegistry.sol";
contract RiskWindowLatchRollbackTest is BaseConfidencePoolTest {
/// @notice Proves that riskWindowStart is not persisted when withdraw() reverts,
/// leaving the withdrawal lock dependent solely on live registry state.
///
/// PoC path:
/// 1. Active risk is observed.
/// 2. The latch write is rolled back.
///A legitimate registry migration exposes the missing historical latch,
/// allowing withdrawals to reopen.
function testRiskWindowLatchNotPersistedOnRevert() external {
// ── Setup ────────────────────────────────────────────────────────
_stake(alice, 100 * ONE);
// ── 1. Precondition: no risk observed yet ────────────────────────
assertEq(pool.riskWindowStart(), 0, "precondition: riskWindowStart == 0");
// ── 2. Registry enters UNDER_ATTACK ─────────────────────────────
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// ── 3. withdraw() reverts — WithdrawsDisabled ────────────────────
// Internally: _observePoolState() writes riskWindowStart,
// then the revert rolls that write back.
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// ── 4. Rollback confirmed: latch was never persisted ─────────────
assertEq(
pool.riskWindowStart(),
0,
"rollback: riskWindowStart written by _observePoolState then rolled back by revert"
);
// ── 5. Simulate a legitimate registry migration ────────────────────────
// ConfidencePool reads the attack-registry pointer live and does not
// pin the previous registry instance. The migration is not the root cause;
// it exposes the impact of the missing historical latch.
MockAttackRegistry newRegistry = new MockAttackRegistry();
newRegistry.setAgreementState(
IAttackRegistry.ContractState.NOT_DEPLOYED
);
safeHarborRegistry.setAttackRegistry(address(newRegistry));
// ── 6. riskWindowStart still zero after migration ────────────────
assertEq(
pool.riskWindowStart(),
0,
"latch still unpersisted: pool has no memory of prior UNDER_ATTACK observation"
);
// ── 7. withdraw() succeeds despite prior active-risk exposure ────
uint256 balanceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.withdraw();
assertEq(
token.balanceOf(alice) - balanceBefore,
100 * ONE,
"staker withdrew full principal after prior active-risk observation"
);
}
}

Recommended Mitigation:

This mitigation directly addresses the reported root cause. Previously, withdraw() called _observePoolState() before the withdrawal guard. When an active-risk state was observed, _observePoolState() attempted to persist riskWindowStart, but the subsequent WithdrawsDisabled() revert rolled back that state change, leaving the historical risk latch unset.

The fix replaces the initial state-mutating observation with the read-only _getAgreementState(). The withdrawal eligibility check is therefore performed without attempting a state change that would inevitably be reverted on the disabled path. _observePoolState() is now called only after the withdrawal gate has passed, ensuring that its intended state changes occur only on a successful transaction and can persist.

This change confirms the underlying issue: calling _observePoolState() before a reverting withdrawal guard could not persist the one-way riskWindowStart latch.

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
- IAttackRegistry.ContractState state = _observePoolState();
+ IAttackRegistry.ContractState state = _getAgreementState();
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
+ _observePoolState();
uint256 amount = eligibleStake[msg.sender];
if (amount == 0) revert InvalidAmount();
.
.
.
}

Support

FAQs

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

Give us feedback!