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

Scope lock can be rolled back, allowing scope changes after the pool observed a post-staging state

Author Revealed upon completion

Severity: Medium

Summary

ConfidencePool.sol::setPoolScope() can lose the first observation that should permanently lock the pool scope.

When the registry is in ATTACK_REQUESTED, _observePoolState() sets scopeLocked = true. But setPoolScope() then reverts because scopeLocked is true. Since the whole transaction reverts, the scopeLocked = true write is rolled back.

If the attack request is later rejected and the registry is observed as NOT_DEPLOYED, the sponsor can call setPoolScope() again and replace the pool scope, even though the pool already observed that the agreement had left pre-attack staging.

Root Cause

setPoolScope() observes the registry and then checks the value that may have just been changed:

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

_observePoolState() locks scope for any state other than NOT_DEPLOYED or NEW_DEPLOYMENT:

if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}

Relevant code:

Why It Matters

The protocol documents describe pool scope as a fixed pool-local commitment. The sponsor can update scope only while the registry is in NOT_DEPLOYED or NEW_DEPLOYMENT. Once the pool observes any other state, scope is intended to become permanently locked.

Scope matters because it is the published audit trail used to decide whether a later registry-level CORRUPTED event was in-scope for this specific pool or out-of-scope.

Because the lock can be rolled back, the sponsor may be able to change the coverage set after the pool already observed ATTACK_REQUESTED.

Attack Path

  1. A pool is created with an initial scope.

  2. Stakers deposit against that published scope.

  3. The registry moves to ATTACK_REQUESTED.

  4. The sponsor calls setPoolScope().

  5. _observePoolState() sets scopeLocked = true.

  6. setPoolScope() reverts with ScopePostLockImmutable().

  7. The revert rolls back scopeLocked = true.

  8. The attack request is later rejected, so the registry is observed as NOT_DEPLOYED.

  9. Since the pool never persisted the lock, the sponsor can call setPoolScope() again and replace the scope.

There is also no clean permissionless way to seal the lock during ATTACK_REQUESTED. pokeRiskWindow() calls _observePoolState(), but then reverts with RiskWindowNotReached because ATTACK_REQUESTED is not an active-risk or terminal state. That revert also rolls back the lock.

Impact

This breaks the documented scope immutability boundary.

The issue does not cause an immediate one-call theft. However, it can affect settlement classification. If the originally covered account is removed after the pool observed ATTACK_REQUESTED, a later CORRUPTED registry state may be evaluated against the mutated scope and settled as SURVIVED.

In the PoC, Alice and Bob stake against the original scope and Carol contributes bonus. After the scope lock is rolled back, the sponsor removes the originally covered account. Later, the registry reaches CORRUPTED, but the pool is settled as SURVIVED. Alice and Bob each claim 125 * ONE, recovery receives no funds, and the pool balance becomes zero.

The affected value can be the full pool balance, but the path depends on sponsor action, a rejected attack request, and later moderator classification. For that reason, Medium is the more appropriate severity.

Proof of Concept

// 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 AuditScopeLockRollbackTest is BaseConfidencePoolTest {
address internal constant ACCOUNT_X = address(0xA1);
address internal constant ACCOUNT_Y = address(0xA2);
function _seedReplacementScope() internal {
agreementContract.setContractInScope(ACCOUNT_X, true);
agreementContract.setContractInScope(ACCOUNT_Y, true);
}
function _replacementScope() internal pure returns (address[] memory accounts) {
accounts = new address[](2);
accounts[0] = ACCOUNT_X;
accounts[1] = ACCOUNT_Y;
}
function testScopeLockObservationIsRolledBackWhenSetPoolScopeReverts() external {
_seedReplacementScope();
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
assertFalse(pool.scopeLocked());
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.isAccountInScope(ACCOUNT_X));
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(_replacementScope());
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(_replacementScope());
assertFalse(pool.scopeLocked());
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertTrue(pool.isAccountInScope(ACCOUNT_X));
assertTrue(pool.isAccountInScope(ACCOUNT_Y));
}
function testPokeRiskWindowAlsoCannotPersistScopeLockInAttackRequested() external {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked());
}
function testAuditScopeLockRollbackCanChangeSettlementDistribution() external {
_seedReplacementScope();
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(_replacementScope());
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
address[] memory reducedScope = new address[](1);
reducedScope[0] = ACCOUNT_X;
pool.setPoolScope(reducedScope);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertTrue(pool.isAccountInScope(ACCOUNT_X));
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
uint256 bobBefore = token.balanceOf(bob);
uint256 recoveryBefore = token.balanceOf(recovery);
vm.prank(alice);
pool.claimSurvived();
vm.prank(bob);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 125 * ONE);
assertEq(token.balanceOf(bob) - bobBefore, 125 * ONE);
assertEq(token.balanceOf(recovery), recoveryBefore);
assertEq(token.balanceOf(address(pool)), 0);
}
}

Run:

forge test --match-path test/unit/AuditScopeLockRollbackTest.t.sol -vvv

Recommended Mitigation

Avoid calling _observePoolState() from setPoolScope() in a way that writes scopeLocked and then reverts. Instead, read the registry state directly and reject scope updates before making any state write that would be rolled back.

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

A separate non-reverting public function could also be added to persist scopeLocked for any post-staging state, including ATTACK_REQUESTED, without requiring the risk window to have started.

Support

FAQs

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

Give us feedback!