ConfidencePool is intended to publish a pool-local scope and then make that scope immutable once the BattleChain registry leaves pre-attack staging. That commitment matters because stakers deposit against a specific set of in-scope BattleChain accounts, and a later CORRUPTED outcome can sweep their principal and bonus to the sponsor-controlled recovery address.
The issue is that the permissionless observer path can see ATTACK_REQUESTED, set scopeLocked = true, and then revert in pokeRiskWindow(). The revert rolls back the lock. When the upstream attack request is later rejected, the real BattleChain registry returns the agreement to NOT_DEPLOYED, leaving the pool with live stake but no persisted scope lock. A sponsor can then replace the original scope and expose existing stakers to a different account set than the one they deposited against. This is a Critical candidate only under a rubric that treats sponsor-enabled full affected-pool loss from a broken scope commitment as Critical; otherwise it should be treated as a high-impact integrity issue with a sponsor-action precondition.
File: src/ConfidencePool.sol
Lines: 636-640
Function/Type: setPoolScope(address[])
File: src/ConfidencePool.sol
Lines: 649-657
Function/Type: pokeRiskWindow()
File: src/ConfidencePool.sol
Lines: 749-776
Function/Type: _replaceScope(address[])
File: src/ConfidencePool.sol
Lines: 784-799
Function/Type: _observePoolState()
File: lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol
Lines: 350-376
Function/Type: rejectAttackRequest(address,bool) integration behavior
_observePoolState() locks the pool scope whenever the registry state is no longer NOT_DEPLOYED or NEW_DEPLOYMENT. This includes ATTACK_REQUESTED, because the agreement has already left pre-attack staging.
pokeRiskWindow() calls _observePoolState(), but then reverts with RiskWindowNotReached whenever neither riskWindowStart nor riskWindowEnd was sealed. During ATTACK_REQUESTED, _observePoolState() only performs the scope lock. It does not open the risk window because ATTACK_REQUESTED is not an active-risk state. The subsequent revert in pokeRiskWindow() therefore rolls back the only useful state write, leaving scopeLocked == false.
This creates a mismatch with the upstream registry lifecycle. The real AttackRegistry.rejectAttackRequest() clears the agreement information and emits NOT_DEPLOYED. Once that happens, setPoolScope() observes a pre-attack state again. Because the attempted ATTACK_REQUESTED lock was reverted, setPoolScope() does not hit ScopePostLockImmutable() and proceeds to _replaceScope(). The sponsor can remove the original scope and install a replacement scope while existing stakers remain in the pool.
The loss path is not immediate at the replacement transaction. The loss occurs when the replacement scope later enters an attack lifecycle and the pool is resolved as CORRUPTED. At that point, claimCorrupted() sweeps the full pool balance, including the existing stakers' principal and bonus, to recoveryAddress.
The root cause is that pokeRiskWindow() treats a scope-only observation as failure and reverts after _observePoolState() has set scopeLocked. The revert undoes the lock even though observing ATTACK_REQUESTED should permanently freeze the pool scope.
Step 1 Stakers deposit into a pool after reviewing the original pool scope.
Step 2 The BattleChain registry moves the agreement to ATTACK_REQUESTED.
Step 3 A keeper or staker calls pokeRiskWindow() to persist the scope lock, but the call reverts and the lock is rolled back.
Step 4 The upstream registry rejects the attack request and returns the agreement to NOT_DEPLOYED.
Result: The sponsor can call setPoolScope() and replace the already-staked pool scope.
Step 1 The sponsor replaces the original scope after the rejected request.
Step 2 The replacement scope later moves through UNDER_ATTACK.
Step 3 The replacement scope reaches CORRUPTED, and the moderator flags the pool as bad-faith CORRUPTED.
Step 4 Any caller executes claimCorrupted().
Result: The full pool balance, including principal deposited against the original scope and bonus funds, is swept to recoveryAddress.
A sponsor creates a pool with an initial scope.
Stakers deposit principal and bonus contributors fund the bonus pool.
The registry reaches ATTACK_REQUESTED, which should permanently lock the scope.
pokeRiskWindow() calls _observePoolState().
_observePoolState() sets scopeLocked = true.
pokeRiskWindow() then reverts with RiskWindowNotReached because no active-risk or terminal marker was sealed.
The revert rolls back scopeLocked.
The upstream registry rejects the attack request and emits NOT_DEPLOYED.
The sponsor calls setPoolScope() while scopeLocked is still false.
_replaceScope() removes the original scope and installs the replacement scope.
A later CORRUPTED lifecycle for the replacement scope allows claimCorrupted() to sweep stake and bonus to recovery.
The practical impact is a broken scope commitment for already-staked pools. Existing stakers can be made to back a different set of BattleChain accounts than the set they evaluated when depositing. If the replacement scope later corrupts, stakers can lose their full principal, and bonus contributors can lose the full bonus pool, through the normal CORRUPTED sweep path.
The blast radius is one affected pool at a time, but the loss within that pool can be complete. The attacker cost is low for a malicious or opportunistic sponsor once the upstream request is rejected. The important precondition is sponsor participation: this is not a fully permissionless theft path. It is strongest as a Critical candidate when the judging rubric treats sponsor-enabled replacement of a staked risk scope followed by full affected-pool loss as Critical.
Framework used: Foundry / forge
File 1: test/poc/ConfidencePoolScopeLockPoC.t.sol
The PoC first proves that pokeRiskWindow() cannot persist the ATTACK_REQUESTED scope lock: after the expected revert, scopeLocked() is still false. It then returns the mock registry to NOT_DEPLOYED, matching the upstream rejectAttackRequest() behavior, and shows that the sponsor can replace the original scope.
The final lifecycle moves the replacement scope through UNDER_ATTACK and CORRUPTED, flags the pool as bad-faith CORRUPTED, and calls claimCorrupted(). The recovery address receives 150 * ONE, which is the full pool value in the test: Alice's 100 * ONE stake plus Carol's 50 * ONE bonus. Alice receives nothing even though she deposited before the scope replacement.
assertFalse(pool.scopeLocked(), "reverted poke rolled back scope lock") -> proves the scope lock is not persisted during ATTACK_REQUESTED -> PASS
assertFalse(pool.isAccountInScope(ORIGINAL_SCOPE), "original commitment removed") -> proves the original staked scope can be removed -> PASS
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE), "replacement commitment installed") -> proves a replacement scope can be installed for the same live pool -> PASS
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "stake and bonus swept") -> proves the later CORRUPTED lifecycle can sweep the full pool value -> PASS
assertEq(token.balanceOf(alice), 0, "original staker receives nothing") -> proves the original staker loses their deposited principal in the demonstrated lifecycle -> PASS
Make pokeRiskWindow() persist scope-only observations instead of reverting after _observePoolState() locks the scope. The function should only revert when absolutely no relevant state was changed.
Add a second hardening check that disallows scope replacement once any stake has entered the pool. This protects stakers even when upstream registry state later returns to a staging state.
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.