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

Reverted Scope-Lock Observations Can Allow Scope Replacement After an Attack Request Is Rejected

Author Revealed upon completion

Root + Impact

Description

The pool is meant to lock its local scope once the registry leaves pre-attack staging. This protects stakers from having the committed pool scope changed after they have deposited into a pool.

The lock is written inside _observePoolState(). However, setPoolScope() first calls _observePoolState(), then reverts when scopeLocked is true. Because the transaction reverts, the just-written scopeLocked = true is rolled back. pokeRiskWindow() cannot persist the lock in ATTACK_REQUESTED either, because that state is not an active-risk or terminal marker and the function reverts with RiskWindowNotReached().

The real upstream registry can move an agreement from ATTACK_REQUESTED back to NOT_DEPLOYED when the request is rejected. That registry transition is legitimate; the issue is that Confidence Pool can observe the non-staging state through reverting code paths without durably recording the documented one-way scope lock. If no successful pool interaction persists the lock before rejection, the sponsor can replace the pool scope after the agreement had already left pre-attack staging once.

Severity is reduced by the constraints around this path: ATTACK_REQUESTED remains withdraw-eligible, no funds move during the rejected request cycle, the highest-impact scenario requires a later restarted cycle, and any successful lock-triggering pool call before rejection permanently prevents the scope replacement.

The scope update path in ConfidencePool.sol::setPoolScope writes the lock only through a call that can later revert:

function setPoolScope(address[] calldata accounts) external onlyOwner {
// @> This can set scopeLocked in _observePoolState()
_observePoolState();
// @> Then this reverts and rolls back the scopeLocked write
if (scopeLocked) revert ScopePostLockImmutable();
// ... code replaces scope only when the lock was not set ...
_replaceScope(accounts);
}

The lock write happens inside ConfidencePool.sol::_observePoolState:

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
// @> This write is only durable if the caller does not later revert
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
}

The permissionless marker entrypoint ConfidencePool.sol::pokeRiskWindow also cannot persist the ATTACK_REQUESTED lock because it reverts:

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
// @> ATTACK_REQUESTED has no risk-window marker, so this reverts and rolls back the lock
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
// ... code emits the observed risk window when one exists ...
}

The upstream rejection path in AttackRegistry.sol::rejectAttackRequest can return the agreement to staging:

function rejectAttackRequest(address agreementAddress, bool slashBond) external onlyRegistryModerator {
ContractState currentState = _getAgreementState(agreementAddress);
if (currentState != ContractState.ATTACK_REQUESTED) {
revert AttackRegistry__InvalidState(currentState);
}
// ... code loads the agreement scope ...
IAgreement agreement = IAgreement(agreementAddress);
address[] memory contracts = agreement.getBattleChainScopeAddresses();
uint256 length = contracts.length;
for (uint256 i; i < length; ++i) {
delete s_contractToAgreement[contracts[i]];
}
// @> The request is rejected back to NOT_DEPLOYED and agreement info is deleted
emit AgreementStateChanged(agreementAddress, ContractState.NOT_DEPLOYED);
delete s_agreementInfo[agreementAddress];
}

After deletion, the state query returns NOT_DEPLOYED in AttackRegistry.sol#L854-L856:

// @> After deletion, getAgreementState returns NOT_DEPLOYED
if (!info.isRegistered) {
return ContractState.NOT_DEPLOYED;
}

Risk

Likelihood: Low

  • A staker deposits while the agreement is still in pre-attack staging.

  • The agreement enters ATTACK_REQUESTED, but the only pool interactions that observe it revert, so scopeLocked is not durably persisted.

  • The registry moderator rejects the attack request, returning the agreement to NOT_DEPLOYED, before any successful pool interaction locks the scope.

Impact: Low

  • The sponsor can replace the local pool scope for an existing-stake pool after the agreement already left pre-attack staging once.

  • Stakers can no longer rely on the originally published scope remaining fixed across a rejected request cycle unless a successful locking interaction occurred.

Proof of Concept

The PoC file included with this report is:

ScopeLockRewindPoC.t.sol

To run it in a fresh contest checkout:

  1. Copy ScopeLockRewindPoC.t.sol into the repository's test/ directory.

  2. Run:

forge test --match-contract ScopeLockRewindPoC -vv

Expected result:

2 passed, 0 failed

The PoC demonstrates:

  • setPoolScope() observes ATTACK_REQUESTED and reverts, rolling back scopeLocked.

  • pokeRiskWindow() also cannot persist the scope lock in ATTACK_REQUESTED because it reverts.

  • After the request is rejected back to NOT_DEPLOYED, the sponsor successfully replaces scope while the original staker remains in the pool.

Inline PoC source (ScopeLockRewindPoC.t.sol):

// 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";
/// @notice PoCs for scope lock timing once the registry leaves pre-attack staging.
/// @dev The ATTACK_REQUESTED -> NOT_DEPLOYED transition models the real AttackRegistry rejection
/// path, which deletes agreement info and makes getAgreementState return NOT_DEPLOYED again.
contract ScopeLockRewindPoC is BaseConfidencePoolTest {
address internal constant ACCOUNT_X = address(0xA1);
address internal constant ACCOUNT_Y = address(0xA2);
function setUp() public override {
super.setUp();
agreementContract.setContractInScope(ACCOUNT_X, true);
agreementContract.setContractInScope(ACCOUNT_Y, true);
}
function _newScope() internal pure returns (address[] memory accounts) {
accounts = new address[](2);
accounts[0] = ACCOUNT_X;
accounts[1] = ACCOUNT_Y;
}
function testRevertedSetPoolScopeObservationLetsSponsorMutateAfterRejectedAttackRequest() external {
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(_newScope());
assertFalse(pool.scopeLocked(), "lock write rolled back with setPoolScope revert");
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "old committed scope still live");
// Real AttackRegistry.rejectAttackRequest emits NOT_DEPLOYED and deletes agreement info.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(_newScope());
assertFalse(pool.scopeLocked(), "scope remains unlocked after rewind to NOT_DEPLOYED");
assertEq(pool.eligibleStake(alice), 100 * ONE, "existing staker remains in pool");
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "original account removed after post-staging cycle");
assertTrue(pool.isAccountInScope(ACCOUNT_X), "new account added after post-staging cycle");
assertTrue(pool.isAccountInScope(ACCOUNT_Y), "new account added after post-staging cycle");
}
function testPokeCannotPersistScopeLockAtAttackRequestedBeforeRejectedAttackRequest() external {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "ATTACK_REQUESTED observation rolled back with poke revert");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(_newScope());
assertFalse(pool.scopeLocked(), "keeper poke did not seal the scope lock");
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertTrue(pool.isAccountInScope(ACCOUNT_X));
}
}

Recommended Mitigation

Make scope-lock observation durable even when setPoolScope() rejects the requested replacement.

The simplest fix is to split the lock check from mutation and avoid writing the lock inside a code path that intentionally reverts. For example, setPoolScope() can persist the lock and return without replacing scope when the registry has already left pre-attack staging:

function setPoolScope(address[] calldata accounts) external onlyOwner {
- _observePoolState();
- if (scopeLocked) revert ScopePostLockImmutable();
+ IAttackRegistry.ContractState state = _getAgreementState();
+ if (
+ state != IAttackRegistry.ContractState.NOT_DEPLOYED
+ && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
+ ) {
+ if (!scopeLocked) {
+ scopeLocked = true;
+ emit ScopeLocked(block.timestamp);
+ }
+ return;
+ }
+ if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

An alternative is to provide a non-reverting lockScope() or make pokeRiskWindow() persist scopeLocked in ATTACK_REQUESTED without requiring a risk-window marker. Then any keeper can seal the one-way commitment before the registry returns to staging.

Support

FAQs

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

Give us feedback!