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

Pool scope has no per-stake lock (unlike expiry): the sponsor can swap the committed scope after stakers deposit, breaking the deposit-time exposure guarantee

Author Revealed upon completion

Root + Impact

src/ConfidencePool.sol — the pool scope (the set of BattleChain accounts whose in-scope breach makes stakers lose their principal) is protected by a WEAKER lock than expiry, despite carrying the same staker reliance.

  • expiry locks on the first stake (expiryLocked, set in stake()). DESIGN.md §10: this "protects staker reliance: once anyone has deposited against a given deadline ... the sponsor cannot move it."

  • scope locks only when the registry leaves pre-attack staging (scopeLocked, set in _observePoolState). It is NOT tied to the first stake.

So the sponsor can call setPoolScope AFTER stakers have deposited (while the registry is still NOT_DEPLOYED / NEW_DEPLOYMENT) and swap the covered accounts to a different, riskier subset. A staker who deposited against scope A ends up underwriting scope B.

Impact: DESIGN.md §8 guarantees "stakers' exposure is bounded by what they signed up for at deposit time." The code does not enforce this. A staker's principal is at risk against a scope they never consented to; if the swapped-in accounts are breached in-scope, the moderator flags bad-faith CORRUPTED and the staker's principal is swept to the sponsor's recoveryAddress. The sponsor recognized this exact reliance for expiry and locked it per-stake, but left scope — which determines whether the staker loses principal at all — mutable post-deposit.

Description

expiry lock in stake():

if (!expiryLocked) {
expiryLocked = true;
}

@> the first stake permanently locks expiry.

scope lock in setPoolScope():

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

@> scopeLocked is set by _observePoolState only when the registry leaves the pre-attack states — never by stake(). Between the first deposit and the registry reaching UNDER_ATTACK, the sponsor can freely replace the scope.

Sequence:

  1. Pool published with scope [SAFE]. Stakers deposit against it (expiryLocked becomes true).

  2. Registry still NEW_DEPLOYMENT. Sponsor calls setPoolScope([RISKY]). Succeeds — no per-stake scope lock.

  3. Registry reaches UNDER_ATTACK; scopeLocked latches [RISKY]; withdraw is disabled.

  4. RISKY is breached in-scope → CORRUPTED bad-faith → stakers' principal swept to the sponsor's recoveryAddress.

Risk

Likelihood: Low — a staker can withdraw while the registry is pre-attack, so to be trapped they must fail to react to the ScopeUpdated event before the risk window opens; the sponsor does not control when UNDER_ATTACK arrives.

Impact: Medium — principal loss: stakers underwrite, and can lose, principal against a scope they never consented to.

Proof of Concept

forge test --match-contract ScopeMutableAfterStakeTest -vv passes against the in-scope contracts:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ScopeMutableAfterStakeTest is BaseConfidencePoolTest {
address internal constant SAFE_ACCOUNT = address(0xC0FFEE); // DEFAULT_SCOPE_ACCOUNT
address internal constant RISKY_ACCOUNT = address(0xBADBAD);
function test_sponsorSwapsScopeAfterStake_whileExpiryIsAlreadyLocked() external {
agreementContract.setContractInScope(RISKY_ACCOUNT, true);
// Alice deposits against the published SAFE scope. Her deposit locks expiry.
_stake(alice, 100 * ONE);
assertTrue(pool.expiryLocked(), "first stake locks expiry");
assertTrue(pool.isAccountInScope(SAFE_ACCOUNT), "staked against SAFE_ACCOUNT");
assertFalse(pool.isAccountInScope(RISKY_ACCOUNT), "RISKY not covered at deposit time");
// expiry is now immutable...
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
pool.setExpiry(block.timestamp + 60 days);
// ...but the sponsor can still swap the pool's committed scope out from under Alice.
address[] memory newScope = new address[](1);
newScope[0] = RISKY_ACCOUNT;
pool.setPoolScope(newScope); // succeeds — no per-stake scope lock
assertTrue(pool.isAccountInScope(RISKY_ACCOUNT), "sponsor added RISKY after Alice staked");
assertFalse(pool.isAccountInScope(SAFE_ACCOUNT), "sponsor dropped the SAFE account she chose");
}
}

Recommended Mitigation

Give scope the same per-stake lock as expiry: set scopeLocked = true in stake() on the first deposit, mirroring expiryLocked. Alternatively, after any stake allow setPoolScope only to NARROW the scope (remove accounts, never add), or add an expectedScopeHash parameter to stake() so a deposit reverts when the live scope differs from what the staker signed.

Support

FAQs

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

Give us feedback!