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

Pool pause is deposit-only and does not stop resolution or fund egress

Author Revealed upon completion

Root + Impact

Description

The pool's pause flag gates only stake() and contributeBonus(). Withdrawals, outcome flagging, risk-window observation, claims, recovery sweeps, and owner configuration remain callable while paused() == true.

This is an intentional inflow-only policy according to the repository's existing unit tests, which state that pause must not let the owner trap stakers. The name pause(), however, does not communicate that narrow scope, and the contract has no separate emergency mode capable of stopping resolution or treasury-directed egress. During a registry or recipient incident, operators can pause new deposits but cannot prevent the post-grace, permissionless auto-CORRUPTED path from finalizing and sending the entire pool to recoveryAddress.

The custom pause modifiers are wired only to the two inbound token functions:

src/ConfidencePool.sol
modifier whenPoolNotPaused() {
if (paused()) revert PoolPaused();
_;
}
function stake(uint256 amount) external nonReentrant whenPoolNotPaused { // @> Inflow is paused.
// ...
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused { // @> Inflow is paused.
// ...
}
function withdraw() external nonReentrant { // @> Principal can leave while paused.
// ...
}
function flagOutcome(...) external onlyModerator { // @> Resolution can advance while paused.
// ...
}
function claimCorrupted() external nonReentrant { // @> The full balance can leave while paused.
// ...
}
function claimExpired() external nonReentrant { // @> Time-based resolution is pause-immune.
// ...
}

The sharpest path occurs when the registry is CORRUPTED and an active risk window was observed. Once expiry + MODERATOR_CORRUPTED_GRACE is reached, any address can call claimExpired() while the pool remains paused:

if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED; // @> Terminal state is fixed despite the pause.
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}

Because bad-faith claimCorrupted() is also ungated, the next caller can transfer the entire pool balance to the current recovery recipient. Pausing therefore offers no response window for investigating a corrupted registry report, a compromised recovery address, or an accounting incident that affects outbound transfers.

The same asymmetry applies more broadly: flagOutcome(), claimSurvived(), claimAttackerBounty(), sweepUnclaimedCorrupted(), sweepUnclaimedBonus(), pokeRiskWindow(), setRecoveryAddress(), setExpiry(), and setPoolScope() do not inspect pause state. Some of these exemptions are defensible individually, especially allowing users to withdraw before risk begins, but together they mean the control is not a general emergency brake.

Risk

Likelihood:

  • The behavior requires no exploit or unusual token. It occurs deterministically whenever the pool is paused and one of the ungated functions is called.

  • The highest-impact scenario requires an incident during or after a CORRUPTED lifecycle and the 180-day moderator grace period to have elapsed.

  • Existing tests explicitly encode the inflow-only design, reducing the chance that maintainers misunderstand it, but external operators and integrators can still reasonably interpret a generic paused() flag more broadly.

Impact:

  • Pause cannot stop terminal resolution, claims, or recovery sweeps, so it cannot contain an incident affecting the registry, moderator decision, recovery destination, or outbound accounting.

  • The PoC shows the full staked principal plus bonus leaving the contract while paused() stays true.

  • Blindly extending the existing pause to withdrawals would introduce a different risk: the owner could trap stakers before the risk window begins. The mitigation must preserve an explicit exit policy.

Proof of Concept

Create test/audit/CP033DepositOnlyPoolPause.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP033DepositOnlyPoolPauseTest is BaseConfidencePoolTest {
function test_PauseBlocksInflowsButNotAutoCorruptedResolutionOrSweep() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 25 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pause();
assertTrue(pool.paused());
vm.prank(bob);
vm.expectRevert(IConfidencePool.PoolPaused.selector);
pool.stake(10 * ONE);
vm.prank(bob);
vm.expectRevert(IConfidencePool.PoolPaused.selector);
pool.contributeBonus(10 * ONE);
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted(), "permissionless resolution finalized while paused");
assertTrue(pool.paused(), "resolution did not lift the pause");
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(dave);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 125 * ONE, "the full pool exited while paused");
assertEq(token.balanceOf(address(pool)), 0);
assertTrue(pool.paused(), "fund egress also left the pool paused");
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP033DepositOnlyPoolPause.t.sol -vv.

  2. Confirm that the test passes. Both deposit-side calls revert with PoolPaused, but after the moderator grace period claimExpired() resolves the paused pool as CORRUPTED and claimCorrupted() transfers all 125 tokens to recovery without unpausing.

Recommended Mitigation

Make the existing policy explicit and add a distinct, narrowly governed emergency-resolution mode. Keep ordinary inflow pause from blocking pre-risk user exits, but let a guardian temporarily stop new resolution and pool-wide egress during a verified incident. The emergency mode should be time-bounded or controlled by a multisig/timelock so it cannot become a permanent seizure mechanism.

src/ConfidencePool.sol
+bool public resolutionPaused;
+uint32 public resolutionPauseExpiresAt;
+
+modifier whenResolutionNotPaused() {
+ if (resolutionPaused && block.timestamp <= resolutionPauseExpiresAt) {
+ revert ResolutionPaused();
+ }
+ _;
+}
+function pauseResolution(uint32 until) external onlyOwner {
+ if (until <= block.timestamp || until > block.timestamp + MAX_RESOLUTION_PAUSE) {
+ revert InvalidPauseDeadline();
+ }
+ resolutionPaused = true;
+ resolutionPauseExpiresAt = until;
+ emit ResolutionPauseUpdated(true, until);
+}
-function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
- external onlyModerator
+function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
+ external onlyModerator whenResolutionNotPaused
{
// ...
}
-function claimExpired() external nonReentrant {
+function claimExpired() external nonReentrant whenResolutionNotPaused {
// ...
}
-function claimCorrupted() external nonReentrant {
+function claimCorrupted() external nonReentrant whenResolutionNotPaused {
// ...
}

Apply the same emergency modifier to other pool-wide resolution and recovery sweeps according to a documented matrix. Decide separately whether already-finalized individual claims should remain available; continuing user claims may be safer than freezing them. Rename or document the current pause() as pauseInflows() at the interface and event level so monitoring systems do not mistake paused() == true for a full contract freeze.

Support

FAQs

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

Give us feedback!