FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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.

Updates

Lead Judging Commences

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

Sponsor can atomically self-trigger ATTACK_REQUESTED and roll back scopeLocked via a reverting setPoolScope/pokeRiskWindow, reopening scope after a DAO rejection

Impact - Low Nothing moves when the scope is swapped, and the state afterward is pre-attack, so any staker who notices can withdraw in full. _replaceScope emits ScopeUpdated, which puts the change on-chain where it can be seen. Real loss needs a restarted attack cycle that breaches one of the substituted accounts with the original staker still in the pool, at which point their principal funds a CORRUPTED payout for coverage they never chose. Likelihood - Medium The step the sponsor cannot force is the moderator rejecting the attack request, though rejections are an ordinary registry operation. The rest they can arrange. Neither observation path can seal the lock in this state, and pausing the pool shuts the deposit routes, leaving only a full-exit withdraw to do it. So reaching the mutable-scope condition is not hard; what holds the severity down is how much has to go wrong afterward for anyone to actually lose money.

Support

FAQs

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

Give us feedback!