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 {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_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
) {
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();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
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);
}
IAgreement agreement = IAgreement(agreementAddress);
address[] memory contracts = agreement.getBattleChainScopeAddresses();
uint256 length = contracts.length;
for (uint256 i; i < length; ++i) {
delete s_contractToAgreement[contracts[i]];
}
emit AgreementStateChanged(agreementAddress, ContractState.NOT_DEPLOYED);
delete s_agreementInfo[agreementAddress];
}
After deletion, the state query returns NOT_DEPLOYED in AttackRegistry.sol#L854-L856:
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:
To run it in a fresh contest checkout:
Copy ScopeLockRewindPoC.t.sol into the repository's test/ directory.
Run:
forge test --match-contract ScopeLockRewindPoC -vv
Expected result:
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):
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";
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");
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.