Root + Impact
Description
-
The pool scope should become permanently immutable on the first interaction that observes the registry outside NOT_DEPLOYED or NEW_DEPLOYMENT. Therefore, observing ATTACK_REQUESTED must persist scopeLocked = true, even though this state does not begin the bonus risk window.
-
pokeRiskWindow() calls _observePoolState(), which sets scopeLocked = true in ATTACK_REQUESTED. Because neither risk-window marker is set in that state, pokeRiskWindow() subsequently reverts with RiskWindowNotReached(), rolling back the scope lock and its event. Following a legitimate upstream rejection from ATTACK_REQUESTED to NOT_DEPLOYED, the sponsor can replace the scope even though the pool already observed the attack request.
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
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);
}
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
Risk
Likelihood:
-
Occurs when the first pool interaction during ATTACK_REQUESTED is the advertised permissionless pokeRiskWindow() call.
-
Becomes exploitable when the registry moderator subsequently rejects the attack request, returning the upstream agreement to NOT_DEPLOYED before another successful pool interaction persists the lock.
Impact:
-
The sponsor can replace the pool's advertised coverage after the protocol lifecycle says that coverage became immutable.
-
Existing stakers can be exposed to a materially different set of insured contracts than the scope they relied upon when staking.
Proof of Concept
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract ScopeLockRollbackPoC is BaseConfidencePoolTest {
address internal constant NEW_SCOPE_ACCOUNT = address(0xBEEF);
function test_PokeRollsBackScopeLockInAttackRequested() external {
agreementContract.setContractInScope(NEW_SCOPE_ACCOUNT, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
address[] memory replacement = new address[](1);
replacement[0] = NEW_SCOPE_ACCOUNT;
pool.setPoolScope(replacement);
assertTrue(pool.isAccountInScope(NEW_SCOPE_ACCOUNT));
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.scopeLocked());
}
}
Recommended Mitigation
Treat locking scope as useful work and do not revert the transaction that first persists it.
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ bool scopeWasUnlocked = !scopeLocked;
_observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
+ if (riskWindowStart == 0 && riskWindowEnd == 0) {
+ // ATTACK_REQUESTED may have performed the one-way scope lock.
+ if (scopeWasUnlocked && scopeLocked) return;
+ revert RiskWindowNotReached();
+ }
}