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

Pool scope remains mutable after first stake, allowing sponsor to change deposited staker exposure before registry lock

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: after a staker deposits, the pool should not let the sponsor change the scope that determines what the deposited principal is underwriting.

  • I analyzed ConfidencePool.sol and observed that the first successful stake() locks expiry, but does not lock the pool scope. While the registry remains in NOT_DEPLOYED or NEW_DEPLOYMENT, the sponsor can call setPoolScope() and replace the scope even after stakers have deposited. The existing stake remains in the pool and can later be swept under the replacement scope.

// src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
@> if (!expiryLocked) {
@> expiryLocked = true;
}
...
}
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 contract recognizes first-stake reliance for expiry by setting expiryLocked, but it does not provide the same protection for scope. Scope is one of the core risk parameters a staker relies on before depositing.

Risk

Likelihood:

  • This occurs when users stake while the registry is still in NOT_DEPLOYED or NEW_DEPLOYMENT, which is a normal pre-attack staging phase.

  • During that phase, scopeLocked remains false and setPoolScope() can still replace the pool-local scope even when totalEligibleStake != 0.

Impact:

  • A staker can deposit under one visible pool scope and later remain exposed under a different sponsor-selected scope.

  • If the replacement scope later reaches CORRUPTED, the staker's existing principal can be swept to recoveryAddress.

Proof of Concept

Create this file:

cat > test/unit/ScopeMutableAfterStakePoC.t.sol <<'EOF'
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ScopeMutableAfterStakePoC is BaseConfidencePoolTest {
address internal constant NEW_RISKY_SCOPE_ACCOUNT = address(0xBEEF);
function testPoC_sponsorCanReplacePoolScopeAfterStakeBeforeRegistryLock() external {
// The agreement contains both accounts.
// The pool initially exposes only DEFAULT_SCOPE_ACCOUNT.
agreementContract.setContractInScope(NEW_RISKY_SCOPE_ACCOUNT, true);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.isAccountInScope(NEW_RISKY_SCOPE_ACCOUNT));
assertFalse(pool.scopeLocked());
// Alice stakes while the pool covers DEFAULT_SCOPE_ACCOUNT.
_stake(alice, 100 * ONE);
assertEq(pool.eligibleStake(alice), 100 * ONE);
assertTrue(pool.expiryLocked(), "first stake locks expiry");
// First stake does not lock the pool scope.
// Sponsor replaces the committed pool scope while Alice's stake remains deposited.
address[] memory newScope = new address[](1);
newScope[0] = NEW_RISKY_SCOPE_ACCOUNT;
pool.setPoolScope(newScope);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "deposit-time scope removed");
assertTrue(pool.isAccountInScope(NEW_RISKY_SCOPE_ACCOUNT), "replacement scope added");
assertEq(pool.eligibleStake(alice), 100 * ONE, "Alice stake remains exposed");
// The replacement scope later reaches active risk and CORRUPTED.
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);
assertEq(token.balanceOf(address(pool)), 0);
}
function testControl_scopeCannotChangeAfterRegistryLock() external {
agreementContract.setContractInScope(NEW_RISKY_SCOPE_ACCOUNT, true);
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked());
address[] memory newScope = new address[](1);
newScope[0] = NEW_RISKY_SCOPE_ACCOUNT;
vm.expectRevert();
pool.setPoolScope(newScope);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.isAccountInScope(NEW_RISKY_SCOPE_ACCOUNT));
}
}
EOF

Run:

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

Observed output:

Ran 2 tests for test/unit/ScopeMutableAfterStakePoC.t.sol:ScopeMutableAfterStakePoC
[PASS] testControl_scopeCannotChangeAfterRegistryLock() (gas: 361952)
[PASS] testPoC_sponsorCanReplacePoolScopeAfterStakeBeforeRegistryLock() (gas: 535830)
Suite result: ok. 2 passed; 0 failed; 0 skipped

The PoC proves that:

  1. Alice deposits while the pool covers DEFAULT_SCOPE_ACCOUNT.

  2. First stake locks expiry, but not scope.

  3. The sponsor replaces the pool scope while Alice's stake remains deposited.

  4. The replacement scope later becomes CORRUPTED.

  5. Alice's deposited principal is swept to recoveryAddress.

Recommended Mitigation

Disallow scope replacement while there is active deposited stake.

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
+ if (totalEligibleStake != 0) revert ScopeChangeWithActiveStake();
_replaceScope(accounts);
}

Alternatively, lock scope on first stake, mirroring the existing expiryLocked behavior.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (!expiryLocked) {
expiryLocked = true;
}
+ if (!scopeLocked) {
+ scopeLocked = true;
+ emit ScopeLocked(block.timestamp);
+ }
...
}

The safer model is to allow sponsor scope updates only before any staker capital is deposited. Once a user has staked, the pool should not allow the sponsor to change the scope that defines the deposited capital's risk exposure

Support

FAQs

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

Give us feedback!