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

`pokeRiskWindow()` rolls back the scope lock when it first observes `ATTACK_REQUESTED`

Author Revealed upon completion

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();
// @> ATTACK_REQUESTED sets scopeLocked but neither risk marker. This revert
// @> rolls back the supposedly one-way scope lock.
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
) {
// @> This write and event are reverted by pokeRiskWindow().
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

// SPDX-License-Identifier: MIT
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);
// ATTACK_REQUESTED is the first state that must permanently lock scope.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
// The transition observed by poke was rolled back with the revert.
assertFalse(pool.scopeLocked());
// A legitimate rejected request returns the agreement to NOT_DEPLOYED.
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();
+ }
}

Support

FAQs

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

Give us feedback!