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

scopeLocked State Never Persisted And ScopeLocked Event Never Emitted When setPoolScope Is The First Observer

Author Revealed upon completion

Description

When the registry has left pre-attack staging (e.g. UNDER_ATTACK), setPoolScope is expected to seal the scope and revert. However, the function calls _observePoolState() which sets scopeLocked = true and emits ScopeLocked(block.timestamp), and then the function checks if (scopeLocked) revert ScopePostLockImmutable(). Because the transaction reverts, all state changes are rolled back — including scopeLocked = true and the emitted event.

The scope IS effectively immutable (every future setPoolScope call will follow the same revert path), but the on-chain state says scopeLocked == false and no ScopeLocked event was ever emitted. This persists until a different function (e.g., stake, withdraw, pokeRiskWindow) calls _observePoolState() in a transaction that succeeds.

// ConfidencePool.sol L636-641
function setPoolScope(address[] calldata accounts) external onlyOwner {
// @> _observePoolState sets scopeLocked = true + emits ScopeLocked
_observePoolState();
// @> This revert rolls back BOTH the state change AND the event
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
// ConfidencePool.sol L784-792 (_observePoolState)
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
// @> These persist only if the outer transaction commits
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
// ...
}

Risk

Likelihood:

  • This occurs every time the pool owner calls setPoolScope after the registry has moved past pre-attack staging, AND no other pool function has been called first to commit the observation. This is the natural first interaction when the owner tries to update scope and discovers it's locked.

  • Pools with low activity are particularly vulnerable — scopeLocked could remain false for extended periods after the registry transitions.

Impact:

  • Off-chain monitoring systems, security dashboards, and keepers that poll pool.scopeLocked() will see false even though scope changes are impossible, creating a data integrity gap.

  • Indexers listening for ScopeLocked events will never index the lock event, leading to stale state in subgraphs and UIs.

  • No direct fund loss — the scope itself cannot be changed — but the state inconsistency undermines the reliability of on-chain view functions that integrators depend on.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, Vm} from "forge-std/Test.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract Finding2_ScopeLockedInconsistency is BaseConfidencePoolTest {
function test_finding2_scopeLockedNotPersistedBySetPoolScope() external {
// Registry moves past pre-attack staging
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
assertFalse(pool.scopeLocked(), "pre-condition: scopeLocked is false");
// setPoolScope reverts — _observePoolState sets scopeLocked=true inside,
// then revert rolls it back
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(_defaultScope());
// BUG: scopeLocked is STILL false after the revert
assertFalse(
pool.scopeLocked(),
"BUG: scopeLocked was rolled back — off-chain view returns false"
);
}
function test_finding2_scopeLockedEventNeverEmittedBySetPoolScope() external {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
bytes32 scopeLockedTopic = keccak256("ScopeLocked(uint256)");
vm.recordLogs();
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(_defaultScope());
Vm.Log[] memory logs = vm.getRecordedLogs();
for (uint256 i; i < logs.length; i++) {
if (logs[i].emitter == address(pool)) {
assertNotEq(
logs[i].topics[0],
scopeLockedTopic,
"BUG: ScopeLocked emitted by pool despite revert"
);
}
}
}
function test_finding2_correctlySetByOtherFunctions() external {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
assertFalse(pool.scopeLocked());
// stake() succeeds → _observePoolState commits → scopeLocked = true
_stake(alice, ONE);
assertTrue(pool.scopeLocked(), "stake() correctly seals scopeLocked");
}
}

Run: forge test --match-contract Finding2_ScopeLockedInconsistency -vvv

Terminal Output:

Ran 2 tests for test/audit/AuditPoC.t.sol:Finding2_ScopeLockedInconsistency
[PASS] test_finding2_scopeLockedCorrectlySetByOtherFunctions() (gas: 317992)
[PASS] test_finding2_scopeLockedNotPersistedBySetPoolScope() (gas: 93888)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 5.77ms (3.52ms CPU time)

Recommended Mitigation

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

Check scopeLocked before calling _observePoolState() so that already-locked scopes revert before any observation. Then call _observePoolState() to detect fresh transitions (which commits and emits), and re-check afterward to catch newly-locked state.

Support

FAQs

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

Give us feedback!