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.
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
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
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 {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
assertFalse(pool.scopeLocked(), "pre-condition: scopeLocked is false");
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(_defaultScope());
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(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.