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

Scope stays mutable after a staker deposits during pre-attack staging, letting the sponsor repoint insured coverage out from under existing stakers (DESIGN §8 "bounded at deposit time" is actually "at lock time")

Author Revealed upon completion

Description

  • Stakers underwrite a specific published scope (the BattleChain accounts the pool insures). DESIGN §8 promises "stakers' exposure is bounded by what they signed up for at deposit time." The pool already locks expiry on the first stake (expiryLocked) for exactly this staker-reliance reason.

  • Scope, however, locks only on the first observation of a non-pre-attack state, not on the first deposit. Because stake() is allowed throughout NOT_DEPLOYED / NEW_DEPLOYMENT and a deposit in those states does not set scopeLocked, the sponsor can call setPoolScope(...) after a staker has deposited and silently repoint the insured coverage. Exposure is therefore bounded at lock time, not deposit time, contradicting the documented guarantee stakers rely on.

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable(); // still false during NOT_DEPLOYED / NEW_DEPLOYMENT
_replaceScope(accounts);
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
@> if (!scopeLocked && state != NOT_DEPLOYED && state != NEW_DEPLOYMENT) { scopeLocked = true; ... }
...
}
// stake() permits deposits in NOT_DEPLOYED/NEW_DEPLOYMENT and never touches scopeLocked -> a live
// deposit does not freeze coverage.

Risk

Likelihood:

  • The sponsor (pool owner) can call setPoolScope at any time while the registry is in NOT_DEPLOYED / NEW_DEPLOYMENT, including after deposits. The trigger is a single owner call and needs no special registry state.

  • The DESIGN doc itself makes the contradictory "bounded at deposit time" promise, so stakers who deposit during pre-attack staging are actively encouraged to rely on coverage that is not yet fixed.

Impact:

  • A staker's insured coverage can change after they commit capital: a sponsor can narrow scope (dropping the account the staker cared about) or broaden it to include an account the sponsor expects to be breached (steering the pool toward a CORRUPTED outcome that sweeps the staker's principal to recoveryAddress).

  • Mitigated, hence Low: the staker can withdraw() for the entire pre-attack window and ScopeUpdated is emitted, so a monitoring staker can exit after a scope change. Harm requires an inattentive staker; there is no atomic trap.

Proof of Concept

Drop into test/audit/ and run forge test --match-contract ScopeMutableAfterDepositPoC -vv (passes against the unmodified contract).

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract ScopeMutableAfterDepositPoC is BaseConfidencePoolTest {
function test_sponsorChangesInsuredScopeAfterStakerDeposits() public {
// Alice deposits during NEW_DEPLOYMENT, relying on scope = [DEFAULT_SCOPE_ACCOUNT].
_stake(alice, 10 ether);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.scopeLocked()); // NOT locked despite a live staker
// Sponsor repoints coverage to a different account, after Alice's deposit.
address other = address(0xBEEF);
agreementContract.setContractInScope(other, true);
address[] memory newScope = new address[](1);
newScope[0] = other;
pool.setPoolScope(newScope);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT)); // original coverage gone
assertTrue(pool.isAccountInScope(other)); // silently repointed
assertEq(pool.eligibleStake(alice), 10 ether); // stake retained under new scope
}
}

Recommended Mitigation

Lock scope on the first stake, mirroring the existing expiryLocked latch (both protect the same deposit-time reliance):

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
if (!expiryLocked) {
expiryLocked = true;
}
+ // Freeze coverage once real capital is committed, so exposure is bounded at deposit time (DESIGN §8).
+ if (!scopeLocked) {
+ scopeLocked = true;
+ emit ScopeLocked(block.timestamp);
+ }
...
}

Trade-off: the sponsor can no longer correct a scope typo after the first deposit, so scope must be finalized before staking opens. Alternatively, if pre-lock scope mutability is intended, correct DESIGN §8 to state that exposure is bounded at scope-lock time (not deposit time) so stakers do not rely on a guarantee the code does not provide.

Support

FAQs

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

Give us feedback!