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

Rejected attack request rolls back scope lock and allows staked pool scope replacement

Author Revealed upon completion

Summary

ConfidencePool is intended to lock its scope once the BattleChain registry leaves pre-attack staging, so existing stakers keep the account commitment they deposited against. The bug is that the permissionless observer path can observe ATTACK_REQUESTED, set scopeLocked = true, and then revert, rolling the lock back.

This becomes exploitable when the upstream attack request is rejected and the agreement returns to NOT_DEPLOYED. Since no successful pool call persisted the scope lock, the sponsor can replace the pool scope after stakers already deposited and after the registry had left pre-attack staging.

Affected Contract

File: src/ConfidencePool.sol

Lines: 636-640

Function/Type: setPoolScope(address[])

File: src/ConfidencePool.sol

Lines: 649-657

Function/Type: pokeRiskWindow()

File: src/ConfidencePool.sol

Lines: 749-776

Function/Type: _replaceScope(address[])

File: src/ConfidencePool.sol

Lines: 784-799

Function/Type: _observePoolState()

File: lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol

Lines: 350-376

Function/Type: rejectAttackRequest(address,bool) integration behavior

Vulnerability Details

_observePoolState() locks scope whenever it observes a registry state other than NOT_DEPLOYED or NEW_DEPLOYMENT. That includes ATTACK_REQUESTED. However, pokeRiskWindow() calls _observePoolState() and then reverts with RiskWindowNotReached when neither riskWindowStart nor riskWindowEnd was sealed.

During ATTACK_REQUESTED, _observePoolState() sets scopeLocked = true, but the following revert in pokeRiskWindow() rolls back that write. A keeper or third party therefore cannot persist the lock through the permissionless observer function in that state.

The real BattleChain AttackRegistry.rejectAttackRequest() clears the agreement info and emits NOT_DEPLOYED. Once the registry is back in a pre-attack state, setPoolScope() observes NOT_DEPLOYED, sees scopeLocked == false, and allows _replaceScope() to remove the original scope and install a new one. Existing stakers remain exposed to the replacement scope.

Root Cause

The root cause is that pokeRiskWindow() reverts after _observePoolState() performs a scope-only lock, so the lock is not persisted when the registry is in ATTACK_REQUESTED.

// File: src/ConfidencePool.sol:649-657
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
// File: src/ConfidencePool.sol:784-799
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();
}
}
// File: src/ConfidencePool.sol:636-640
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
// File: src/ConfidencePool.sol:749-776
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
address[] memory old = _scopeAccounts;
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}
// File: lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol:350-376
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];
}

Attack Vectors

Attack Vector 1: Scope replacement after rejected request

Step 1 Stakers deposit against an initial pool scope.
Step 2 The registry reaches ATTACK_REQUESTED.
Step 3 The attack request is rejected upstream and the agreement returns to NOT_DEPLOYED.
Step 4 The sponsor calls setPoolScope() and replaces the original scope.
Result: Existing stakers are now economically exposed to a different scope than the one they deposited against.

Attack Vector 2: Failed keeper lock

Step 1 A keeper calls pokeRiskWindow() during ATTACK_REQUESTED to persist the scope lock.
Step 2 _observePoolState() sets scopeLocked = true.
Step 3 pokeRiskWindow() reverts because no risk-window start or end was sealed.
Step 4 The revert rolls back scopeLocked, and a later rejection returns the registry to NOT_DEPLOYED.
Result: The sponsor can still replace the scope for the already-staked pool.

Execution Flow

  • The pool sponsor sets an initial scope.

  • Stakers deposit principal and bonus contributors fund the pool.

  • The registry enters ATTACK_REQUESTED.

  • A permissionless pokeRiskWindow() call observes the state and attempts to lock scope.

  • pokeRiskWindow() reverts with RiskWindowNotReached, rolling back the lock.

  • The upstream registry rejects the attack request and returns the agreement to NOT_DEPLOYED.

  • The sponsor calls setPoolScope() while scopeLocked is still false.

  • _replaceScope() removes the original scope and installs the replacement scope.

  • A later CORRUPTED lifecycle can sweep stake and bonus based on the replacement scope.

Impact

This is a Medium severity integrity issue for the pool's committed scope. Existing stakers can be left backing a different set of BattleChain accounts than the scope they evaluated when depositing. If the replacement scope later corrupts, their principal and the bonus pool can be swept to recovery.

The attacker cost is low for a malicious or opportunistic sponsor once the upstream request is rejected. The practical blast radius is all current stakers in that pool, because scope replacement is wholesale and affects the pool-level commitment.

Validation steps

Standalone Validation

Framework used: Foundry / forge

Validation Test File

File 1: test/poc/ConfidencePoolScopeLockPoC.t.sol

function testPoC_rejectedAttackRequestLetsSponsorReplaceStakedPoolScope() external {
agreementContract.setContractInScope(ORIGINAL_SCOPE, true);
agreementContract.setContractInScope(REPLACEMENT_SCOPE, true);
address[] memory originalScope = new address[](1);
originalScope[0] = ORIGINAL_SCOPE;
pool.setPoolScope(originalScope);
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
assertTrue(pool.isAccountInScope(ORIGINAL_SCOPE));
assertFalse(pool.isAccountInScope(REPLACEMENT_SCOPE));
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "reverted poke rolled back scope lock");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
address[] memory replacementScope = new address[](1);
replacementScope[0] = REPLACEMENT_SCOPE;
pool.setPoolScope(replacementScope);
assertFalse(pool.isAccountInScope(ORIGINAL_SCOPE), "original commitment removed");
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE), "replacement commitment installed");
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, 150 * ONE, "stake and bonus swept");
assertEq(token.balanceOf(alice), 0, "original staker receives nothing");
}

How to Run

forge test --match-path test/poc/ConfidencePoolScopeLockPoC.t.sol -vvv

Captured Test Output

Ran 1 test for test/poc/ConfidencePoolScopeLockPoC.t.sol:ConfidencePoolScopeLockPoC
[PASS] testPoC_rejectedAttackRequestLetsSponsorReplaceStakedPoolScope() (gas: 661010)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Log-to-Impact Walkthrough

The passing PoC proves that pokeRiskWindow() reverts in ATTACK_REQUESTED and leaves scopeLocked == false. After the registry is returned to NOT_DEPLOYED, the sponsor replaces the original scope. A later CORRUPTED flow sweeps 150 * ONE to recovery and leaves Alice with zero balance.

Key Assertions Proven

assertFalse(pool.scopeLocked(), "reverted poke rolled back scope lock") -> proves the attempted lock is not persisted -> PASS
assertFalse(pool.isAccountInScope(ORIGINAL_SCOPE)) and assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE)) -> proves the staked pool scope is replaced -> PASS
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE) -> proves stake and bonus can be swept after the replacement lifecycle -> PASS

Recommended Fix

Make pokeRiskWindow() persist scope-only observations instead of reverting after _observePoolState() locks scope. A stronger mitigation is to also disallow scope replacement while any stake remains live, even when the registry later returns to a pre-attack state.

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

Support

FAQs

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

Give us feedback!