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

Scope locks on an ATTACK_REQUESTED observation but never unlocks after a benign `rejectAttackRequest` rewind, freezing the pool's scope while the agreement is back in pre-attack staging

Author Revealed upon completion

Root + Impact

Description

  • DESIGN.md §8 says the sponsor can update scope freely while the registry is in NOT_DEPLOYED / NEW_DEPLOYMENT (pre-attack staging), and scope locks once past it.

  • _observePoolState sets the one-way scopeLocked on the first observation of any state other than NOT_DEPLOYED / NEW_DEPLOYMENT, which includes ATTACK_REQUESTED. The registry's rejectAttackRequest legitimately rewinds ATTACK_REQUESTED back to NOT_DEPLOYED (the agreement can then be re-registered, possibly with a different scope), but the pool's scopeLocked latch never resets, so the pool refuses any scope change while the agreement is back in staging.

// src/ConfidencePool.sol :: _observePoolState
state = _getAgreementState();
if (
@> !scopeLocked && state != NOT_DEPLOYED && state != NEW_DEPLOYMENT // ATTACK_REQUESTED locks scope here
) {
@> scopeLocked = true; // one-way; never reset even after rejectAttackRequest rewinds to NOT_DEPLOYED
emit ScopeLocked(block.timestamp);
}

Risk

Likelihood:

  • Occurs when a pool interaction observes the agreement in ATTACK_REQUESTED (locking scope), and the DAO then calls rejectAttackRequest, rewinding the agreement to NOT_DEPLOYED.

  • After the rewind the sponsor's setPoolScope reverts ScopePostLockImmutable even though the agreement is back in pre-attack staging and may be re-registered with a different scope.

Impact:

  • The pool's committed scope can permanently diverge from the underlying agreement earlier and more sharply than §8 contemplates.

  • No direct fund loss, since scope is not consulted on-chain at resolution (the moderator judges off-chain); hence Low.

Proof of Concept

// test/Metatron_Loop.t.sol :: MetatronLoopPoC
function test_H08_scopeLockSurvivesBenignRewind() public {
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_stake(alice, 10 ether);
assertTrue(pool.scopeLocked(), "scope locked on ATTACK_REQUESTED observation");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED); // benign reject/rewind
address[] memory newScope = new address[](1);
newScope[0] = DEFAULT_SCOPE_ACCOUNT;
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(newScope);
assertTrue(pool.scopeLocked(), "scope still locked despite pre-attack staging");
}

Run:

forge test --match-test test_H08_scopeLockSurvivesBenignRewind -vv

Result:

Ran 3 tests for test/Metatron_Loop.t.sol:MetatronLoopPoC
[PASS] test_H08_scopeLockSurvivesBenignRewind() (gas: 320786)
Suite result: ok. 3 passed; 0 failed; 0 skipped

Recommended Mitigation

Lock scope only on an observation that cannot benignly rewind (first active-risk or terminal state), not on ATTACK_REQUESTED:

- if (!scopeLocked && state != NOT_DEPLOYED && state != NEW_DEPLOYMENT) {
+ if (!scopeLocked && state != NOT_DEPLOYED && state != NEW_DEPLOYMENT && state != ATTACK_REQUESTED) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}

Support

FAQs

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

Give us feedback!