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

ATTACK_REQUESTED poke rolls back the documented scope lock

Author Revealed upon completion

Root + Impact

Description

  • Expected behavior: the first pool interaction that observes a registry state outside NOT_DEPLOYED / NEW_DEPLOYMENT should persistently lock the pool scope. pokeRiskWindow is the permissionless observation hook for pool-local registry markers.

  • Issue: in ATTACK_REQUESTED, _observePoolState sets scopeLocked = true, but pokeRiskWindow then reverts with RiskWindowNotReached because no active-risk or terminal marker was sealed. The revert rolls back the scope lock, so a later registry rewind to NOT_DEPLOYED lets the owner replace the funded scope.

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);
}
}

This is not direct fund loss. It is an incorrect one-way state transition: the permissionless hook observes ATTACK_REQUESTED but cannot persist the scope lock it creates.

Risk

Likelihood:

  • Medium. ATTACK_REQUESTED is a normal registry state, and attack requests can be rejected back to NOT_DEPLOYED.

  • Any caller can reach the failed observation through pokeRiskWindow.

Impact:

  • Low. Funds are not directly lost, but the pool remains scope-mutable after an attempted ATTACK_REQUESTED observation. A funded pool can later have its covered scope replaced if no other successful pool call persisted the lock.

Proof of Concept

The native Foundry PoC stakes into the default scope, observes ATTACK_REQUESTED, then rewinds the registry and replaces scope:

function testPocPokeInAttackRequestedDoesNotPersistScopeLock() external {
_seedAgreementScope();
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(_multiAccountScope());
assertTrue(pool.isAccountInScope(ACCOUNT_X));
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
}

Test location:

test/unit/ConfidencePool.scope.t.sol::testPocPokeInAttackRequestedDoesNotPersistScopeLock

Targeted command:

forge test --match-test testPocPokeInAttackRequestedDoesNotPersistScopeLock -vvv

Observed output:

Ran 1 test for test/unit/ConfidencePool.scope.t.sol:ConfidencePoolScopeTest
[PASS] testPocPokeInAttackRequestedDoesNotPersistScopeLock() (gas: 529367)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

Make pokeRiskWindow succeed when _observePoolState persists any one-way observation, including scopeLocked.

- _observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
+ bool changed = _observePoolState();
+ if (!changed) revert RiskWindowNotReached();

One implementation is to have _observePoolState return whether it locked scope, opened the risk window, or sealed the terminal timestamp.

Support

FAQs

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

Give us feedback!