Normal behavior is that the pool scope is a fixed on-chain commitment once the linked registry leaves pre-attack staging. Stakers should not have their deposited capital redirected to a different pool-local coverage set after the agreement has entered a non-staging state.
The issue is that scopeLocked only records the current registry state observed during a successful pool transaction. The upstream AttackRegistry can move from ATTACK_REQUESTED back to NOT_DEPLOYED when the DAO rejects the request. If no successful ConfidencePool transaction persists the ATTACK_REQUESTED observation before that rejection, the pool later sees only NOT_DEPLOYED and allows the owner to replace scope while existing stake remains deposited.
This does not dispute the documented rule that the sponsor may update scope while the registry is in NOT_DEPLOYED or NEW_DEPLOYMENT. The issue is narrower: the registry already left pre-attack staging through ATTACK_REQUESTED, but every available pool-side observer that could persist that fact rolls back during ATTACK_REQUESTED. After the DAO rejection returns the registry to NOT_DEPLOYED, the pool treats existing deposited stake as if the non-staging interval never happened.
During ATTACK_REQUESTED, pokeRiskWindow() temporarily sets scopeLocked = true, but no risk window starts because ATTACK_REQUESTED is not an active-risk state. The function then reverts with RiskWindowNotReached(), rolling back the scope lock. setPoolScope() also temporarily observes the lock, then reverts with ScopePostLockImmutable() and rolls back the observation.
The upstream registry rejection path returns the agreement to NOT_DEPLOYED:
After that rejection, the ConfidencePool has no persisted record that the registry left staging. The sponsor can replace the pool scope, then a later approved attack can lock the replacement scope and disable withdrawals. If the replacement scope is later corrupted, the existing staker's principal can be swept even though they deposited against the original scope.
Likelihood:
Reason 1: This occurs when the registry enters ATTACK_REQUESTED, no successful pool transaction persists scopeLocked, and the DAO rejects the request back to NOT_DEPLOYED.
Reason 2: The sponsor can then call the normal owner-only setPoolScope() entrypoint while existing stake remains deposited. pokeRiskWindow() and a reverted setPoolScope() call during ATTACK_REQUESTED do not protect stakers because both roll back the lock.
Impact:
Impact 1: Existing deposited stake can be redirected from the originally published pool scope to a different replacement scope after the registry has already left staging once.
Impact 2: During a later active-risk/corrupted lifecycle, the staker's principal can be swept based on the replacement scope rather than the scope they deposited against.
Create test/unit/ConfidencePool.rejectedAttackScopeMutation.poc.t.sol with the following full test contract:
Run:
Observed result:
The --skip flag excludes an unrelated local malformed-implementation PoC that currently prevents full test-tree compilation in this workspace; it is not part of this finding.
Live BattleChain testnet fork confirmation:
The fork proof uses the live Safe Harbor registry 0x0a652e265336a0296816aC4D8400880e3E537C24, live AttackRegistry 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350, and live agreement 0xaD7Ba3b8BA7FF4ee48C914b65e76b97025eb3179.
At block 17091, the selected agreement is still NOT_DEPLOYED; the test deploys a local ConfidencePool against the live agreement and Alice stakes into the original live scope. At block 17092, the real deployed registry reports the agreement as ATTACK_REQUESTED from transaction 0xb862d84d1bea8438cdaf3205a2930a99d769cd5bad35298b61aef6bfaf09180b.
The live agreement owner then adds 0x000000000000000000000000000000000000BEEF to the live agreement through the deployed Agreement.addAccounts() path. During ATTACK_REQUESTED, both pokeRiskWindow() and setPoolScope() still roll back the observed scope lock. The test then impersonates the live registry moderator returned by getRegistryModerator() and calls the deployed AttackRegistry.rejectAttackRequest(). The registry returns to NOT_DEPLOYED, after which the same pool owner can call setPoolScope([0xBEEF]) while Alice's existing stake remains deposited.
Run:
Observed result:
The fork proof confirms the live deployed rejection path and reopened pool-local scope mutation. The local unit PoC above completes the later approved-attack/CORRUPTED settlement leg, because the selected live testnet agreement was not driven through a second real approved attack and corruption.
The safest fix is to bind scope immutability to deposited capital, mirroring the existing expiryLocked behavior. Once a user has stake in the pool, the owner should not be able to rewrite the coverage set for that deposited principal.
If post-stake scope changes must remain supported, the pool needs a monotonic upstream signal such as everLeftPreAttackStaging or everAttackRequested. A reversible current-state read cannot prove whether the agreement previously left staging.
As defense in depth, pokeRiskWindow() should treat a newly persisted scopeLocked value as useful work instead of reverting when no risk marker changed. That improves observability, but it is not sufficient by itself because the whole ATTACK_REQUESTED interval can still pass without a successful pool call.
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.