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

Reverting observers can undo the one-way scope lock

Author Revealed upon completion

Root + Impact

Description

_observePoolState() sets scopeLocked when it sees any post-staging registry state, but setPoolScope() and pokeRiskWindow() can revert after that write. Transaction rollback erases the supposedly permanent observation, so a later registry rewind can let the owner replace scope after the agreement already left staging.

The scope is intended to become immutable on the first pool interaction that observes a registry state other than NOT_DEPLOYED or NEW_DEPLOYMENT. This prevents the sponsor from changing the coverage commitment after the attack lifecycle begins.

The lock is written inside _observePoolState(), but two callers deliberately revert in ATTACK_REQUESTED: setPoolScope() sees the newly set lock and throws ScopePostLockImmutable, while pokeRiskWindow() sees that neither risk marker was set and throws RiskWindowNotReached. EVM atomicity rolls scopeLocked and ScopeLocked back with the rest of either call.

src/ConfidencePool.sol
function setPoolScope(address[] calldata accounts) external onlyOwner {
// aderyn-ignore-next-line(unchecked-return)
_observePoolState(); // @> ATTACK_REQUESTED writes scopeLocked = true.
if (scopeLocked) revert ScopePostLockImmutable(); // @> The revert erases that write and event.
_replaceScope(accounts);
}
function pokeRiskWindow() external {
// No-op once resolved: the snapshot globals are frozen, so the risk-window markers must
// be too.
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
// aderyn-ignore-next-line(unchecked-return)
_observePoolState(); // @> ATTACK_REQUESTED locks scope but sets no risk marker.
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached(); // @> Rollback erases the lock.
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true; // @> This is only one-way across successful transactions.
emit ScopeLocked(block.timestamp);
}
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}

The upstream state machine can legitimately reject an attack request and return the agreement from ATTACK_REQUESTED to NOT_DEPLOYED. After a reverted observation fails to persist the lock, that rewind makes setPoolScope() succeed, replacing the pool's original coverage list. A separate successful pool interaction during ATTACK_REQUESTED would preserve the lock, but neither of the explicit observation routes guarantees it.

Risk

Likelihood:

  • The sequence requires the registry to reach ATTACK_REQUESTED, every relevant pool observation to revert, and the attack request then to be rejected back to NOT_DEPLOYED before a successful lock-persisting interaction.

  • The pool owner must then exercise the reopened scope setter; all replacements still require agreement-scope validation and are visible on-chain.

Impact:

  • Existing stakers can have the pool-local coverage commitment changed after the agreement has already left staging, violating the documented immutability boundary.

  • The rewind happens before active risk, limiting immediate fund exposure, but the changed scope can later affect the moderator's breach-in-scope judgment and therefore pool resolution.

Proof of Concept

Create test/audit/CP006RevertingObserverScopeLock.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 {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP006RevertingObserverScopeLockTest is BaseConfidencePoolTest {
address internal constant REPLACEMENT_SCOPE_ACCOUNT = address(0xC001);
function test_RevertedObservationLetsRegistryRewindReopenScopeReplacement() external {
agreementContract.setContractInScope(REPLACEMENT_SCOPE_ACCOUNT, true);
address[] memory replacementScope = _replacementScope();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(replacementScope);
assertFalse(pool.scopeLocked(), "the revert erases the supposedly one-way lock");
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "the failed call restores the old scope");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(replacementScope);
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE_ACCOUNT));
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.scopeLocked(), "the rewound state leaves future replacements open");
}
function test_RevertingPokeAlsoErasesTheScopeLock() external {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "poke rollback erases its post-staging observation");
}
function _replacementScope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = REPLACEMENT_SCOPE_ACCOUNT;
}
}

Run the PoC from the repository root:

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

  2. Confirm that both tests pass: the rejected setter and rejected poke each leave scopeLocked == false, and a registry rewind lets the replacement scope succeed.

Recommended Mitigation

A transaction cannot both persist a new lock and revert. Let observation-only state changes complete successfully, while clearly signaling that a requested scope update was not applied. Likewise, do not revert pokeRiskWindow() when it successfully sealed scope even though no risk marker changed.

src/ConfidencePool.sol
+event ScopeUpdateSkippedAfterLock(address indexed caller);
function setPoolScope(address[] calldata accounts) external onlyOwner {
// aderyn-ignore-next-line(unchecked-return)
_observePoolState();
- if (scopeLocked) revert ScopePostLockImmutable();
+ if (scopeLocked) {
+ emit ScopeUpdateSkippedAfterLock(msg.sender);
+ return;
+ }
_replaceScope(accounts);
}
function pokeRiskWindow() external {
// No-op once resolved: the snapshot globals are frozen, so the risk-window markers must
// be too.
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
// aderyn-ignore-next-line(unchecked-return)
_observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
+ if (riskWindowStart == 0 && riskWindowEnd == 0 && !scopeLocked) {
+ revert RiskWindowNotReached();
+ }
}

An alternative is a dedicated permissionless sealPoolScope() call that never reverts after a qualifying post-staging observation, combined with explicit documentation that reverted calls do not count as observations.

Support

FAQs

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

Give us feedback!