The pool is meant to lock its local scope once the registry leaves pre-attack staging. This protects stakers from having the committed pool scope changed after they have deposited into a pool.
The lock is written inside _observePoolState(). However, setPoolScope() first calls _observePoolState(), then reverts when scopeLocked is true. Because the transaction reverts, the just-written scopeLocked = true is rolled back. pokeRiskWindow() cannot persist the lock in ATTACK_REQUESTED either, because that state is not an active-risk or terminal marker and the function reverts with RiskWindowNotReached().
The real upstream registry can move an agreement from ATTACK_REQUESTED back to NOT_DEPLOYED when the request is rejected. That registry transition is legitimate; the issue is that Confidence Pool can observe the non-staging state through reverting code paths without durably recording the documented one-way scope lock. If no successful pool interaction persists the lock before rejection, the sponsor can replace the pool scope after the agreement had already left pre-attack staging once.
Severity is reduced by the constraints around this path: ATTACK_REQUESTED remains withdraw-eligible, no funds move during the rejected request cycle, the highest-impact scenario requires a later restarted cycle, and any successful lock-triggering pool call before rejection permanently prevents the scope replacement.
The scope update path in ConfidencePool.sol::setPoolScope writes the lock only through a call that can later revert:
The lock write happens inside ConfidencePool.sol::_observePoolState:
The permissionless marker entrypoint ConfidencePool.sol::pokeRiskWindow also cannot persist the ATTACK_REQUESTED lock because it reverts:
The upstream rejection path in AttackRegistry.sol::rejectAttackRequest can return the agreement to staging:
After deletion, the state query returns NOT_DEPLOYED in AttackRegistry.sol#L854-L856:
Likelihood: Low
A staker deposits while the agreement is still in pre-attack staging.
The agreement enters ATTACK_REQUESTED, but the only pool interactions that observe it revert, so scopeLocked is not durably persisted.
The registry moderator rejects the attack request, returning the agreement to NOT_DEPLOYED, before any successful pool interaction locks the scope.
Impact: Low
The sponsor can replace the local pool scope for an existing-stake pool after the agreement already left pre-attack staging once.
Stakers can no longer rely on the originally published scope remaining fixed across a rejected request cycle unless a successful locking interaction occurred.
The PoC file included with this report is:
To run it in a fresh contest checkout:
Copy ScopeLockRewindPoC.t.sol into the repository's test/ directory.
Run:
Expected result:
The PoC demonstrates:
setPoolScope() observes ATTACK_REQUESTED and reverts, rolling back scopeLocked.
pokeRiskWindow() also cannot persist the scope lock in ATTACK_REQUESTED because it reverts.
After the request is rejected back to NOT_DEPLOYED, the sponsor successfully replaces scope while the original staker remains in the pool.
Inline PoC source (ScopeLockRewindPoC.t.sol):
Make scope-lock observation durable even when setPoolScope() rejects the requested replacement.
The simplest fix is to split the lock check from mutation and avoid writing the lock inside a code path that intentionally reverts. For example, setPoolScope() can persist the lock and return without replacing scope when the registry has already left pre-attack staging:
An alternative is to provide a non-reverting lockScope() or make pokeRiskWindow() persist scopeLocked in ATTACK_REQUESTED without requiring a risk-window marker. Then any keeper can seal the one-way commitment before the registry returns to staging.
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.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
View preliminary resultsAppeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.