ConfidencePool.sol::setPoolScope() can lose the first observation that should permanently lock the pool scope.
When the registry is in ATTACK_REQUESTED, _observePoolState() sets scopeLocked = true. But setPoolScope() then reverts because scopeLocked is true. Since the whole transaction reverts, the scopeLocked = true write is rolled back.
If the attack request is later rejected and the registry is observed as NOT_DEPLOYED, the sponsor can call setPoolScope() again and replace the pool scope, even though the pool already observed that the agreement had left pre-attack staging.
setPoolScope() observes the registry and then checks the value that may have just been changed:
_observePoolState() locks scope for any state other than NOT_DEPLOYED or NEW_DEPLOYMENT:
Relevant code:
setPoolScope():
https://github.com/CodeHawks-Contests/2026-07-bc-confidence-
_observePoolState():
https://github.com/CodeHawks-Contests/2026-07-bc-confidence-
scopeLocked = true:
https://github.com/CodeHawks-Contests/2026-07-bc-confidence-
Why It Matters
The protocol documents describe pool scope as a fixed pool-local commitment. The sponsor can update scope only while the registry is in NOT_DEPLOYED or NEW_DEPLOYMENT. Once the pool observes any other state, scope is intended to become permanently locked.
Scope matters because it is the published audit trail used to decide whether a later registry-level CORRUPTED event was in-scope for this specific pool or out-of-scope.
Because the lock can be rolled back, the sponsor may be able to change the coverage set after the pool already observed ATTACK_REQUESTED.
A pool is created with an initial scope.
Stakers deposit against that published scope.
The registry moves to ATTACK_REQUESTED.
The sponsor calls setPoolScope().
_observePoolState() sets scopeLocked = true.
setPoolScope() reverts with ScopePostLockImmutable().
The revert rolls back scopeLocked = true.
The attack request is later rejected, so the registry is observed as NOT_DEPLOYED.
Since the pool never persisted the lock, the sponsor can call setPoolScope() again and replace the scope.
There is also no clean permissionless way to seal the lock during ATTACK_REQUESTED. pokeRiskWindow() calls _observePoolState(), but then reverts with RiskWindowNotReached because ATTACK_REQUESTED is not an active-risk or terminal state. That revert also rolls back the lock.
This breaks the documented scope immutability boundary.
The issue does not cause an immediate one-call theft. However, it can affect settlement classification. If the originally covered account is removed after the pool observed ATTACK_REQUESTED, a later CORRUPTED registry state may be evaluated against the mutated scope and settled as SURVIVED.
In the PoC, Alice and Bob stake against the original scope and Carol contributes bonus. After the scope lock is rolled back, the sponsor removes the originally covered account. Later, the registry reaches CORRUPTED, but the pool is settled as SURVIVED. Alice and Bob each claim 125 * ONE, recovery receives no funds, and the pool balance becomes zero.
The affected value can be the full pool balance, but the path depends on sponsor action, a rejected attack request, and later moderator classification. For that reason, Medium is the more appropriate severity.
Run:
Avoid calling _observePoolState() from setPoolScope() in a way that writes scopeLocked and then reverts. Instead, read the registry state directly and reject scope updates before making any state write that would be rolled back.
A separate non-reverting public function could also be added to persist scopeLocked for any post-staging state, including ATTACK_REQUESTED, without requiring the risk window to have started.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.