FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: low
Likelihood: medium

scopeLocked never un-seals after DAO rejectAttackRequest rewinds registry to NOT_DEPLOYED, permanently bricking setPoolScope

Author Revealed upon completion

Description

// @audit scopeLocked set once in _observePoolState, NEVER reset
// @audit setPoolScope permanently gated: if (scopeLocked) revert ScopePostLockImmutable()
// @audit DAO rejectAttackRequest rewinds registry to NOT_DEPLOYED — but scopeLocked stays true

ConfidencePool seals scopeLocked on the first interaction observing any registry state beyond pre-attack staging (line 787). After this, setPoolScope permanently reverts with ScopePostLockImmutable. DESIGN.md §8 states the sponsor can update scope freely while the registry is in NOT_DEPLOYED.

// @audit scopeLocked set once, NEVER reset
if (!scopeLocked && state != NOT_DEPLOYED && state != NEW_DEPLOYMENT) {
scopeLocked = true;
}
// @audit setPoolScope permanently gated, no check for registry rewind
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

The BattleChain AttackRegistry allows the DAO to call rejectAttackRequest, which deletes the agreement's registered state, rewinding to NOT_DEPLOYED. This is the exact state §8 says scope should be mutable. But scopeLocked has no un-seal mechanism — setPoolScope remains bricked even after a legitimate DAO rejection. The registry's own NatSpec treats re-registration after a soft reject as a normal workflow. Any staker interaction during ATTACK_REQUESTED seals the latch before the DAO can decide. Zero test coverage.

Proof of Concept

The PoC below shows the sequence: scope is set, registry enters ATTACK_REQUESTED, a staker seals scopeLocked, the DAO rejects and rewinds to NOT_DEPLOYED, and the sponsor's setPoolScope call reverts despite the registry being in the §8-mutable state.

pool.setPoolScope([accountA]);
mockRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.prank(alice);
pool.stake(1 ether);
assertTrue(pool.scopeLocked());
mockRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
vm.prank(owner);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope([accountA, accountB]); // @audit reverts despite NOT_DEPLOYED

Risk

rejectAttackRequest is a documented DAO workflow — the registry's NatSpec explicitly anticipates re-registration after a soft reject. scopeLocked can be sealed permissionlessly by any staker interaction during ATTACK_REQUESTED (minimum 7 days) before the DAO can decide.

Impact

Documented sponsor capability permanently denied in a state §8 promises mutability. Degrades on-chain audit trail. No direct fund loss.

Recommended Mitigation

Un-seal scopeLocked when the registry rewinds to pre-attack staging, since the upstream registry has a legitimate rewind path.

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (!scopeLocked && state != NOT_DEPLOYED && state != NEW_DEPLOYMENT) {
scopeLocked = true;
}
+ if (scopeLocked && state == NOT_DEPLOYED) {
+ scopeLocked = false;
+ }
...
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!