FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

[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();
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Sponsor can atomically self-trigger ATTACK_REQUESTED and roll back scopeLocked via a reverting setPoolScope/pokeRiskWindow, reopening scope after a DAO rejection

Impact - Low Nothing moves when the scope is swapped, and the state afterward is pre-attack, so any staker who notices can withdraw in full. _replaceScope emits ScopeUpdated, which puts the change on-chain where it can be seen. Real loss needs a restarted attack cycle that breaches one of the substituted accounts with the original staker still in the pool, at which point their principal funds a CORRUPTED payout for coverage they never chose. Likelihood - Medium The step the sponsor cannot force is the moderator rejecting the attack request, though rejections are an ordinary registry operation. The rest they can arrange. Neither observation path can seal the lock in this state, and pausing the pool shuts the deposit routes, leaving only a full-exit withdraw to do it. So reaching the mutable-scope condition is not hard; what holds the severity down is how much has to go wrong afterward for anyone to actually lose money.

Support

FAQs

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

Give us feedback!