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

ATTACK_REQUESTED poke rollback lets sponsors replace a staker's permanently committed scope

Author Revealed upon completion

pokeRiskWindow() rolls back the permanent scope lock in ATTACK_REQUESTED

Suggested form selections:

  • Impact: Medium

  • Likelihood: Low

  • Resulting severity: Low

  • Affected file: src/ConfidencePool.sol

Root + Impact

Description

A pool's account scope is supposed to become a permanent commitment as soon as the registry leaves NOT_DEPLOYED or NEW_DEPLOYMENT. This bounds what an existing staker has agreed to underwrite.

pokeRiskWindow() does not persist that lock when the first observed post-staging state is ATTACK_REQUESTED:

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) {
revert RiskWindowNotReached();
}
}

_observePoolState() correctly sets scopeLocked = true for ATTACK_REQUESTED, since that state is neither NOT_DEPLOYED nor NEW_DEPLOYMENT:

if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}

However, ATTACK_REQUESTED is not an active-risk or terminal state, so both risk-window timestamps remain zero. pokeRiskWindow() then reverts. EVM atomicity rolls back both scopeLocked = true and the ScopeLocked event.

This matters because the real AttackRegistry.rejectAttackRequest() explicitly permits the registry to move from ATTACK_REQUESTED back to NOT_DEPLOYED. After that valid rewind, setPoolScope() sees a staging state and succeeds because the attempted lock was erased.

An existing staker can therefore remain deposited while the sponsor replaces the account subset the staker originally underwrote. If a newly added account is corrupted in a later attack cycle, the moderator can correctly flag the pool CORRUPTED under the replacement scope and the staker's pre-existing principal is swept. Under the original scope, that same breach could have been out of scope and resolved SURVIVED.

This contradicts the documented guarantee that scope locks permanently once the registry leaves pre-attack staging. It is not a malicious-registry scenario: ATTACK_REQUESTED -> NOT_DEPLOYED is the normal DAO rejection path in the in-repository dependency.

Relevant source:

Risk

Likelihood:

  • Stake must exist before the request.

  • No successful stake, bonus contribution, or withdrawal may persist the lock while the request is pending.

  • The DAO must reject the request, returning the registry to NOT_DEPLOYED.

  • The sponsor must replace the pool scope before a later attack cycle.

  • A sponsor can pause stake and bonus entrypoints during the request. The only remaining staker action that persists the lock is withdraw(), which requires the staker to exit the pool entirely. The intended free keeper hook is the path that fails.

These are specific but protocol-supported conditions, so likelihood is low rather than impossible.

Impact:

  • The pool loses its promised permanent exposure bound.

  • Existing capital can be made to underwrite a different contract subset without a new deposit or explicit staker consent.

  • The proof of concept demonstrates the entire pre-existing principal entering a later CORRUPTED sweep under the replacement scope.

Because the fund loss is indirect and stakers retain a withdrawal window if they detect the lifecycle change, Medium impact / Low likelihood (Low severity) is the conservative classification.

Proof of Concept

Add test/audit/ScopeLockRollback.t.sol with the following test and run:

forge test --match-contract ScopeLockRollbackTest -vvvv

Observed result:

[PASS] testAttackRequestedPokeRollbackAllowsScopeReplacementAfterReject()
Suite result: ok. 1 passed; 0 failed; 0 skipped

The trace shows ScopeLocked emitted inside pokeRiskWindow() immediately before RiskWindowNotReached; the transaction rollback leaves scopeLocked() false. The later scope replacement succeeds while Alice's 100-token stake remains, and claimCorrupted() transfers all 100 tokens to the recovery address.

The test mock only stages the exact enum transitions needed for isolation. The backward transition itself is implemented by the real, in-repository AttackRegistry.rejectAttackRequest() linked above; the vulnerable state rollback occurs in the in-scope ConfidencePool.

// 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 ScopeLockRollbackTest is BaseConfidencePoolTest {
address internal constant RISKY_SCOPE_ACCOUNT = address(0xBAD);
function testAttackRequestedPokeRollbackAllowsScopeReplacementAfterReject() external {
// Both accounts are already valid subsets of the agreement. Alice deposits while the
// pool covers only DEFAULT_SCOPE_ACCOUNT.
agreementContract.setContractInScope(RISKY_SCOPE_ACCOUNT, true);
_stake(alice, 100 * ONE);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.isAccountInScope(RISKY_SCOPE_ACCOUNT));
// The sponsor pauses the two non-exit entrypoints that could otherwise persist an
// observation. ATTACK_REQUESTED has left staging, so a keeper tries the free hook.
pool.pause();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
// The revert erased the lock that _observePoolState() set before reverting.
assertFalse(pool.scopeLocked(), "revert rolled the one-way lock back");
// AttackRegistry.rejectAttackRequest() performs this valid backward transition.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
// The sponsor can now replace the insured subset while Alice's original stake remains.
address[] memory replacement = new address[](1);
replacement[0] = RISKY_SCOPE_ACCOUNT;
pool.setPoolScope(replacement);
assertEq(pool.eligibleStake(alice), 100 * ONE);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertTrue(pool.isAccountInScope(RISKY_SCOPE_ACCOUNT));
// A later attack cycle corrupts the replacement account. The moderator correctly treats
// that account as in scope, and Alice's stake is part of the bad-faith sweep.
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(alice), 0);
}
}

Recommended Mitigation

Treat a newly persisted scope lock as useful work in pokeRiskWindow(), even when neither risk-window timestamp changed:

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

Add a regression test that moves the registry to ATTACK_REQUESTED, calls pokeRiskWindow() successfully, rewinds the registry to NOT_DEPLOYED, and confirms setPoolScope() still reverts with ScopePostLockImmutable.

Support

FAQs

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

Give us feedback!