ConfidencePool is intended to lock its scope once the BattleChain registry leaves pre-attack staging, so existing stakers keep the account commitment they deposited against. The bug is that the permissionless observer path can observe ATTACK_REQUESTED, set scopeLocked = true, and then revert, rolling the lock back.
This becomes exploitable when the upstream attack request is rejected and the agreement returns to NOT_DEPLOYED. Since no successful pool call persisted the scope lock, the sponsor can replace the pool scope after stakers already deposited and after the registry had left pre-attack staging.
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 scope whenever it observes a registry state other than NOT_DEPLOYED or NEW_DEPLOYMENT. That includes ATTACK_REQUESTED. However, pokeRiskWindow() calls _observePoolState() and then reverts with RiskWindowNotReached when neither riskWindowStart nor riskWindowEnd was sealed.
During ATTACK_REQUESTED, _observePoolState() sets scopeLocked = true, but the following revert in pokeRiskWindow() rolls back that write. A keeper or third party therefore cannot persist the lock through the permissionless observer function in that state.
The real BattleChain AttackRegistry.rejectAttackRequest() clears the agreement info and emits NOT_DEPLOYED. Once the registry is back in a pre-attack state, setPoolScope() observes NOT_DEPLOYED, sees scopeLocked == false, and allows _replaceScope() to remove the original scope and install a new one. Existing stakers remain exposed to the replacement scope.
The root cause is that pokeRiskWindow() reverts after _observePoolState() performs a scope-only lock, so the lock is not persisted when the registry is in ATTACK_REQUESTED.
Step 1 Stakers deposit against an initial pool scope.
Step 2 The registry reaches ATTACK_REQUESTED.
Step 3 The attack request is rejected upstream and the agreement returns to NOT_DEPLOYED.
Step 4 The sponsor calls setPoolScope() and replaces the original scope.
Result: Existing stakers are now economically exposed to a different scope than the one they deposited against.
Step 1 A keeper calls pokeRiskWindow() during ATTACK_REQUESTED to persist the scope lock.
Step 2 _observePoolState() sets scopeLocked = true.
Step 3 pokeRiskWindow() reverts because no risk-window start or end was sealed.
Step 4 The revert rolls back scopeLocked, and a later rejection returns the registry to NOT_DEPLOYED.
Result: The sponsor can still replace the scope for the already-staked pool.
The pool sponsor sets an initial scope.
Stakers deposit principal and bonus contributors fund the pool.
The registry enters ATTACK_REQUESTED.
A permissionless pokeRiskWindow() call observes the state and attempts to lock scope.
pokeRiskWindow() reverts with RiskWindowNotReached, rolling back the lock.
The upstream registry rejects the attack request and returns the agreement to 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 can sweep stake and bonus based on the replacement scope.
This is a Medium severity integrity issue for the pool's committed scope. Existing stakers can be left backing a different set of BattleChain accounts than the scope they evaluated when depositing. If the replacement scope later corrupts, their principal and the bonus pool can be swept to recovery.
The attacker cost is low for a malicious or opportunistic sponsor once the upstream request is rejected. The practical blast radius is all current stakers in that pool, because scope replacement is wholesale and affects the pool-level commitment.
Framework used: Foundry / forge
File 1: test/poc/ConfidencePoolScopeLockPoC.t.sol
The passing PoC proves that pokeRiskWindow() reverts in ATTACK_REQUESTED and leaves scopeLocked == false. After the registry is returned to NOT_DEPLOYED, the sponsor replaces the original scope. A later CORRUPTED flow sweeps 150 * ONE to recovery and leaves Alice with zero balance.
assertFalse(pool.scopeLocked(), "reverted poke rolled back scope lock") -> proves the attempted lock is not persisted -> PASS
assertFalse(pool.isAccountInScope(ORIGINAL_SCOPE)) and assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE)) -> proves the staked pool scope is replaced -> PASS
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE) -> proves stake and bonus can be swept after the replacement lifecycle -> PASS
Make pokeRiskWindow() persist scope-only observations instead of reverting after _observePoolState() locks scope. A stronger mitigation is to also disallow scope replacement while any stake remains live, even when the registry later returns to a pre-attack 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.