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

Sponsor can change pool scope after a staker deposits, violating the documented deposit-time exposure guarantee

Author Revealed upon completion

Root + Impact

Description

  • Per the design doc (§8, treated as the binding source of truth), the pool's intended invariant is: "post-stake additions to the underlying agreement do not extend this pool's coverage, and stakers' exposure is bounded by what they signed up for at deposit time." This implies each staker's relevant scope commitment should be the scope that was published at the time they deposited.

  • The setPoolScope is gated purely on scopeLocked — a flag that is itself driven only by registry state (_observePoolState), with zero dependency on whether any staker has already deposited. Scope changes remain possible for as long as the registry is observed in NOT_DEPLOYED / NEW_DEPLOYMENT, and stake() is independently permitted during that exact same window. This means a staker can deposit under one published scope, and the sponsor can subsequently replace that scope entirely before it lock, breaking the given rule.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
@> _assertDepositsAllowed(_observePoolState()); // allowed in NOT_DEPLOYED / NEW_DEPLOYMENT — no scope snapshot taken
...
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable(); // only gate — no check on prior deposits
_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);
}
...
}

Risk

Likelihood:

  • This occurs whenever a staker deposits while the registry is still in NOT_DEPLOYED or NEW_DEPLOYMENT — the pool's normal, expected staking window per its own lifecycle — and the sponsor subsequently calls setPoolScope again before the registry transitions past that staging window.

Impact:

  • A staker's actual, locked-in coverage can differ from the scope they observed and relied on when depositing, directly contradicting the invariant the design doc states as ground truth (§8: exposure bounded by deposit-time signup).

Proof of Concept

// 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";
import {console} from "forge-std/Test.sol";
contract ConfidencePoolLazyRiskWindowStartTest is BaseConfidencePoolTest {
function testScopeChangesAfterStakerDepositsButBeforeLock() external {
// agreement is still in staging; stakers can deposit and scope can be edited.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
// Publish scope S1 = { DEFAULT_SCOPE_ACCOUNT } (set at initialize()).
// alice deposits, relying on the currently published scope S1.
_stake(alice, 100 * ONE);
// Sponsor (owner) now changes scope to S2, a disjoint set — permitted because
// scopeLocked is still false (registry hasn't left staging).
address newAccount = makeAddr("newAccount");
agreementContract.setContractInScope(newAccount, true);
address[] memory newScope = new address[](1);
newScope[0] = newAccount;
pool.setPoolScope(newScope);
// Registry now moves to ATTACK_REQUESTED. pokeRiskWindow() would revert here
// (ATTACK_REQUESTED is neither active-risk nor terminal, so nothing gets sealed
// and RiskWindowNotReached fires). Instead, bob staking is still permitted in
// ATTACK_REQUESTED and triggers _observePoolState() as a side effect, which
// locks scope on this same call.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_stake(bob, 100 * ONE);
assertTrue(pool.scopeLocked());
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "alice's deposit-time scope is gone");
assertTrue(pool.isAccountInScope(newAccount), "locked scope is S2, not S1 alice signed up for");
// alice's real, binding exposure (S2) differs entirely from what she "signed up
// for at deposit time" (S1) — contradicting the doc's stated invariant (§8).
}
}

Recommended Mitigation

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
+ // Enforce the documented invariant: once any stake exists, scope is no longer
+ if (totalEligibleStake != 0) revert ScopeImmutableAfterFirstStake();
_replaceScope(accounts);
}

Support

FAQs

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

Give us feedback!