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

[Low] Reverting `setPoolScope()` does not persist the post-staging scope lock

Author Revealed upon completion

[Low] Reverting setPoolScope() does not persist the post-staging scope lock

Description

The pool documentation states that scope becomes immutable once the registry first leaves pre-attack staging (NOT_DEPLOYED / NEW_DEPLOYMENT).

setPoolScope() calls _observePoolState() before checking scopeLocked.

When the registry is already in ATTACK_REQUESTED, _observePoolState() sets scopeLocked = true, but the function then immediately reverts ScopePostLockImmutable(). That revert rolls back the lock write as well.

As a result, the pool can observe a post-staging state without permanently latching scopeLocked.

If the upstream registry later rewinds to NOT_DEPLOYED through a normal lifecycle transition such as rejecting an attack request, the sponsor can still mutate scope even though the pool already observed ATTACK_REQUESTED once.

Risk

This breaks the documented immutability boundary for the pool scope. Sponsors can change the covered account set after stakers have already deposited and after the pool has already seen a post-staging state once.

The impact is weaker than a direct fund-loss bug because users still retain the normal pre-risk withdrawal path after the rewind, but the pool's advertised one-way scope commitment is not actually one-way.

Proof of Concept

function testRevertingSetPoolScopeInAttackRequestedDoesNotPersistScopeLock() external {
address secondScopeAccount = makeAddr("secondScopeAccount");
agreementContract.setContractInScope(secondScopeAccount, true);
_stake(alice, 100 * ONE);
address[] memory expandedScope = new address[](2);
expandedScope[0] = DEFAULT_SCOPE_ACCOUNT;
expandedScope[1] = secondScopeAccount;
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(expandedScope);
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(expandedScope);
address[] memory scopeAccounts = pool.getScopeAccounts();
assertEq(scopeAccounts.length, 2);
}

PoC flow:

  1. The owner attempts setPoolScope() while the registry is already in ATTACK_REQUESTED.

  2. _observePoolState() sets scopeLocked = true.

  3. The function reverts, so the write is rolled back.

  4. The registry later returns to NOT_DEPLOYED.

  5. The owner can successfully call setPoolScope() and expand the scope even though the pool already observed a post-staging state.

Mitigation

Do not rely on a lock written inside a transaction that is expected to revert as the durable source of truth for scope immutability.

One fix is to gate scope updates on a direct registry-state check before any reverting observation path:

IAttackRegistry.ContractState state = _getAgreementState();
if (scopeLocked) revert ScopePostLockImmutable();
if (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
revert ScopePostLockImmutable();
}

Support

FAQs

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

Give us feedback!