pokeRiskWindow(), the permissionless function designed to let anyone observe the registry state and seal pool state markers cannot persist scopeLocked = true when the registry is in ATTACK_REQUESTED. The function's internal call to _observePoolState() correctly sets scopeLocked = true (since ATTACK_REQUESTED is not NOT_DEPLOYED or NEW_DEPLOYMENT), but pokeRiskWindow() then reverts with RiskWindowNotReached because ATTACK_REQUESTED is neither an active-risk nor a terminal state, rolling back the scope lock write. If the DAO subsequently rejects the attack request via rejectAttackRequest(), the registry returns to NOT_DEPLOYED, and the sponsor can freely change the pool's scope via setPoolScope(), despite stakers having committed funds against the original scope during a state that DESIGN.md defines as scope-locking.
DESIGN.md establishes a clear scope-locking guarantee:
"The sponsor can update scope freely while the registry is in
NOT_DEPLOYED/NEW_DEPLOYMENT(pre-attack staging). Scope locks permanently on the first interaction observing any other state."
The phrase "any other state" explicitly includes ATTACK_REQUESTED, it is neither NOT_DEPLOYED nor NEW_DEPLOYMENT. Under this guarantee, the first interaction that observes ATTACK_REQUESTED should permanently lock the scope.
_observePoolState() correctly implements this guarantee:
However, pokeRiskWindow() wraps this call with its own revert guard:
When the registry is in ATTACK_REQUESTED:
_observePoolState() runs. ATTACK_REQUESTED ≠ NOT_DEPLOYED and ≠ NEW_DEPLOYMENT, so it sets scopeLocked = true.
ATTACK_REQUESTED is not an active-risk state (_isActiveRiskState returns false, only UNDER_ATTACK and PROMOTION_REQUESTED qualify), so riskWindowStart stays 0.
ATTACK_REQUESTED is not a terminal state (_isTerminalState returns false, only PRODUCTION and CORRUPTED qualify at line 837-838), so riskWindowEnd stays 0.
Back in pokeRiskWindow(), line 657 checks: riskWindowStart == 0 && riskWindowEnd == 0 → true → reverts with RiskWindowNotReached.
The EVM revert rolls back all state changes in the transaction, including the scopeLocked = true write from step 1.
The scope lock that _observePoolState() correctly set is erased because the calling function reverts.
This matters because the ATTACK_REQUESTED state can be reversed. The AttackRegistry's rejectAttackRequest() function deletes the agreement info, returning the state to NOT_DEPLOYED:
After rejection, the registry reads NOT_DEPLOYED. Since scopeLocked was never persisted, the sponsor can call setPoolScope():
_observePoolState() sees NOT_DEPLOYED and does not set scopeLocked. The if (scopeLocked) check passes. The sponsor replaces the pool's scope accounts.
Other functions that call _observePoolState() have varying ability to persist the scope lock during ATTACK_REQUESTED:
| Function | Can persist lock during ATTACK_REQUESTED? |
Limitation |
|---|---|---|
pokeRiskWindow() |
No — reverts RiskWindowNotReached |
The dedicated permissionless observation function fails |
stake() |
Yes — ATTACK_REQUESTED is not blocked by _assertDepositsAllowed |
Gated by whenPoolNotPaused; blocked if owner pauses the pool |
contributeBonus() |
Yes — same as stake() |
Also gated by whenPoolNotPaused |
withdraw() |
Yes — ATTACK_REQUESTED is explicitly allowed |
Forces the staker to exit entirely, forfeiting bonus claim |
setPoolScope() |
No — sets lock then immediately reverts ScopePostLockImmutable |
Self-blocks in the same call |
claimExpired() |
Only post-expiry | Reverts if block.timestamp < expiry |
flagOutcome() |
Moderator-only | Not permissionless |
When the pool is paused (a rational owner action during an attack request), the only permissionless path that could persist scope lock is withdraw(), which forces a full exit. No permissionless non-destructive observation path exists during ATTACK_REQUESTED when the pool is paused. This directly contradicts DESIGN.md §8's guarantee that scope locks "on the first interaction observing any other state," since the permissionless observation function cannot fulfill that guarantee during ATTACK_REQUESTED.
Stakers deposit via stake() while registry is NOT_DEPLOYED. Scope is unlocked. Stakers commit funds against the current scope.
A whitehat calls requestUnderAttack() on the AttackRegistry. State transitions to ATTACK_REQUESTED.
The pool owner calls pause() (rational behavior during a pending attack review).
A staker calls pokeRiskWindow() to lock scope — the call reverts with RiskWindowNotReached. Scope lock is not persisted.
With the pool paused, stake() and contributeBonus() also revert (whenPoolNotPaused). The only remaining path is withdraw(), which forces a full exit.
The DAO calls rejectAttackRequest(). Registry returns to NOT_DEPLOYED.
The owner calls unpause(), then setPoolScope() with a different set of scope accounts. The call succeeds because scopeLocked is still false.
Existing stakers who did not withdraw and do not actively re-check scope are now exposed to a different pool scope than the one they evaluated when depositing.
DESIGN.md states:
"Once locked, the pool's coverage is fixed even if the sponsor later expands the agreement, so post-stake additions to the underlying agreement do NOT extend this pool's coverage, and stakers' exposure is bounded by what they signed up for at deposit time."
The scope-locking mechanism exists specifically to protect stakers from having their risk exposure changed after they commit funds. The finding breaks this guarantee: stakers deposit against scope [A, B, C], the agreement briefly enters ATTACK_REQUESTED, the request is rejected, and the sponsor changes scope to [A, B, D]. Contract D might have a much higher risk profile than C, but stakers are now insuring it without having consented.
The practical impact is limited by several factors:
Stakers can withdraw() during ATTACK_REQUESTED and after the rejection (since withdrawals are open pre-risk). They can re-evaluate and exit if unhappy with the new scope.
However, the deviation from the documented guarantee is clear, and the attack path uses only standard protocol operations.
The simplest fix is to make pokeRiskWindow() not revert when it successfully observes a non-staging state, even if no risk window markers were set. This allows the scope lock to persist for ATTACK_REQUESTED:
With this change, if _observePoolState() set scopeLocked = true (meaning a non-staging state was observed), the function completes successfully and the lock persists, even if no risk window markers were set yet. The revert still fires if the registry is in NOT_DEPLOYED/NEW_DEPLOYMENT (where scopeLocked would remain false), preserving the original behavior for true pre-staging calls.
Create a file at test/audit/PoC_ScopeLockRevert.t.sol and paste the code below, then run with:
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.