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

Rejected Attack Request Permanently Locks ConfidencePool Scope Despite AttackRegistry Resetting the Agreement to NOT_DEPLOYED

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: Per docs/DESIGN.md §8, pool scope locks permanently once the underlying agreement has genuinely progressed past pre-attack staging — the intent being that once real risk activity begins, stakers' coverage commitment is frozen as the binding audit trail.

  • The issue: The lock fires on any observation of a state other than NOT_DEPLOYED/NEW_DEPLOYMENT — including the transient ATTACK_REQUESTED state, which is far from "real risk has begun." If the DAO subsequently rejects that attack request via rejectAttackRequest (a normal, routine governance action, not misuse), AttackRegistry deletes the agreement's info entirely and the registry reports NOT_DEPLOYED again — as if nothing ever happened. But ConfidencePool.scopeLocked is one-way and never resets, so the pool is permanently unable to have its scope edited, based on a request that was fully undone.

// src/ConfidencePool.sol
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
@> scopeLocked = true; // Fires on ATTACK_REQUESTED too — a transient, reversible
@> // state, not evidence of genuine progress into risk. One-way: never resets even
@> // if the registry itself later reverts this exact agreement to NOT_DEPLOYED.
emit ScopeLocked(block.timestamp);
}
...
}
// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol
function rejectAttackRequest(address agreementAddress, bool slashBond) external onlyRegistryModerator {
...
@> emit AgreementStateChanged(agreementAddress, ContractState.NOT_DEPLOYED);
@> delete s_agreementInfo[agreementAddress]; // Full reset — the registry now reports
@> // this agreement never requested an attack at all.
...
}

Risk

Likelihood: Medium — occurs whenever a sponsor takes the normal, expected first step (requestUnderAttack/requestUnderAttackForUnverifiedContracts) and the DAO rejects that specific request for any administrative reason, a routine governance path already built into AttackRegistry. A single pool interaction by anyone (not just the sponsor) is enough to observe ATTACK_REQUESTED and seal the lock before the rejection even happens.

Impact: Low — no funds are ever at risk; this is a pure loss of a documented sponsor capability (scope editing), not a fund-theft path. The sponsor permanently loses the ability to correct that specific pool's scope, even though the registry now looks identical to a never-touched agreement. Any staker who already deposited before the rejection is committed to a scope the sponsor can never fix, with no connection to any actual attack having occurred, until stakers coordinate withdrawing and migrating to a freshly created pool for the same agreement (the factory does support many pools per agreement, so this is a real but bounded workaround).

Proof of Concept

Fork test against the live BattleChain testnet (test/fork/BattleChainRejectedAttackScopeLock.fork.t.sol, run with BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-path 'test/fork/*'). Deploys a fresh Agreement via the real AgreementFactory, creates a pool, drives the real registry through ATTACK_REQUESTED → soft-reject → NOT_DEPLOYED, and confirms the pool's lock survives:

IAttackRegistry(ATTACK_REGISTRY).requestUnderAttackForUnverifiedContracts(agreement);
// registry: ATTACK_REQUESTED
stakeToken.mint(sponsor, 1e18);
stakeToken.approve(poolAddr, 1e18);
pool.stake(1e18); // observes ATTACK_REQUESTED -> locks scope
assertTrue(pool.scopeLocked());
vm.prank(registryModerator);
IAttackRegistry(ATTACK_REGISTRY).rejectAttackRequest(agreement, false);
assertEq(uint256(IAttackRegistry(ATTACK_REGISTRY).getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.NOT_DEPLOYED)); // registry: fully reset
assertTrue(pool.scopeLocked()); // pool: still locked, forever
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(scope);

Recommended Mitigation

Only lock scope on states that represent genuine progress into risk — the same boundary already used for risk-window semantics — rather than on ATTACK_REQUESTED, which the registry itself can cleanly reject and reset.

// src/ConfidencePool.sol
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
+ && state != IAttackRegistry.ContractState.ATTACK_REQUESTED
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
...

Note this is consistent with the pool's own existing philosophy elsewhere: ATTACK_REQUESTED is already one of the states where withdraw() stays open (stakers can still freely exit), so scope remaining editable through that same state doesn't trap anyone or contradict any other guarantee — it only defers the lock to the point real risk-relevant activity actually begins.

Support

FAQs

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

Give us feedback!