Root + Impact
Description
-
Normally, a pool's scope becomes permanently locked once the BattleChain registry leaves pre-attack staging. This is meant to prevent the sponsor from replacing the covered accounts after stakers have already deposited against a specific agreement scope.
The issue is that pokeRiskWindow() calls _observePoolState() and then reverts when no risk-window timestamp was sealed. During ATTACK_REQUESTED, _observePoolState() sets scopeLocked = true, but does not set riskWindowStart or riskWindowEnd, so pokeRiskWindow() reverts and rolls the scope lock back. The upstream BattleChain registry can then reject the attack request and return the agreement to NOT_DEPLOYED, after which the sponsor can call setPoolScope() and replace the scope while existing stake remains accounted.
function setPoolScope(address[] calldata accounts) external onlyOwner {
@> _observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
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:
-
This occurs when stakers have deposited while the agreement is in pre-attack staging, the registry moves to ATTACK_REQUESTED, and the only pool observation is a pokeRiskWindow() call that reverts after tentatively setting scopeLocked.
-
The real BattleChain registry supports the relevant rollback path: a rejected attack request can return the agreement from ATTACK_REQUESTED to NOT_DEPLOYED, reopening the sponsor's setPoolScope() path because the pool did not persist the earlier observation.
Impact:
-
The sponsor can replace the pool's covered BattleChain accounts after the registry has already left pre-attack staging, contradicting the promised permanent scope lock.
-
Existing stakers' economic signal can be moved from the originally advertised scope to a different sponsor-selected scope. The impact is reduced because stakers can withdraw again once the registry is back in NOT_DEPLOYED, but passive stakers or watchers who relied on pokeRiskWindow() do not get the intended protection.
Proof of Concept
```solidity
function testAuditScopeCanChangeAfterUnobservedAttackRequestIsRejected() external {
address replacementScopeAccount = address(0xBEEF);
agreementContract.setContractInScope(replacementScopeAccount, true);
_stake(alice, 100 * ONE);
assertFalse(pool.scopeLocked());
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] = replacementScopeAccount;
pool.setPoolScope(replacement);
assertEq(pool.eligibleStake(alice), 100 * ONE);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertTrue(pool.isAccountInScope(replacementScopeAccount));
}
Recommended Mitigation
function pokeRiskWindow() external {
// No-op once resolved: the snapshot globals are frozen, so the risk-window markers must
// be too.
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ bool wasScopeLocked = scopeLocked;
// Revert only when nothing has been sealed - registry never reached active risk or
// a terminal state.
// aderyn-ignore-next-line(unchecked-return)
_observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
+ if (
+ wasScopeLocked == scopeLocked
+ && riskWindowStart == 0
+ && riskWindowEnd == 0
+ ) {
+ revert RiskWindowNotReached();
+ }
}
```
Alternatively, add a dedicated permissionless `pokeScopeLock()` path, or make `_observePoolState()` report whether it locked scope so `pokeRiskWindow()` can preserve that state change even when no risk-window timestamp was updated.