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

Agreement-wide risk can permanently disable withdraw for subset-scoped stakers

Author Revealed upon completion

Root + Impact

Description

Confidence Pools allow a pool to commit to only a subset of the underlying BattleChain Safe Harbor agreement scope. For example, a pool can be scoped only to contract X, even when the wider agreement later contains other contracts.

The issue is that the pool's withdraw() exit right is not evaluated against the pool's committed scope. It is disabled as soon as the agreement-wide registry state enters active risk. This means activity from a sibling contract Y, outside the pool's published scope and even added after the pool scope has already locked, can permanently disable withdrawal for X-only stakers.

This breaks the practical meaning of subset scoping for the exit path. The moderator can later consider pool scope when deciding the final SURVIVED / CORRUPTED outcome, but there is no equivalent scope-aware check before withdraw() is permanently disabled.

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
// @> withdraw observes agreement-wide registry state
IAttackRegistry.ContractState state = _observePoolState();
// @> once riskWindowStart is set, withdrawal is permanently blocked
if (
riskWindowStart != 0
|| (
state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED
)
) {
revert WithdrawsDisabled();
}
...
}
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);
}
// @> any agreement-wide active-risk state seals this pool's risk window
// @> there is no check that the active-risk contract is in this pool's scope
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
// @> reads only agreement-wide state for the whole agreement
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

Risk

Likelihood:

  • This occurs when a pool is scoped to only part of a multi-contract agreement and the agreement enters UNDER_ATTACK because of a sibling contract outside the pool's committed scope.

  • This also occurs when the sibling contract is added to the agreement after the pool's own scope has already locked, because the pool continues to read only agreement-wide lifecycle state.

Impact:

  • Stakers in a subset-scoped pool permanently lose their pre-resolution withdraw() exit right because of activity on a contract they did not opt into for that pool.

  • Capital that was meant to be exposed only to the pool's published scope becomes locked for the rest of the pool lifecycle based on agreement-wide state, before any moderator can make a scope-aware outcome decision.

Proof of Concept

Add the following file:

test/poc/SubsetScopeSiblingLifecycle.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract SubsetScopeSiblingLifecycleProbe is Test {
uint256 internal constant ONE = 1e18;
address internal constant X = address(0xC0FFEE);
address internal constant Y = address(0xBEEF);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePool internal pool;
address internal sponsor = makeAddr("sponsor");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal staker = makeAddr("staker");
address internal bonusContributor = makeAddr("bonusContributor");
function setUp() external {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(sponsor);
agreement.setContractInScope(X, true);
agreement.setContractInScope(Y, true);
safeHarborRegistry.setAgreementValid(address(agreement), true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory poolScope = new address[](1);
poolScope[0] = X;
pool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
sponsor,
poolScope
);
}
function test_subsetScopedPoolLocksWithdrawOnAgreementRiskThatCouldComeFromSiblingY() external {
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
assertEq(pool.getScopeAccounts().length, 1, "pool advertises a one-account scope");
assertEq(pool.getScopeAccounts()[0], X, "pool advertises only X");
assertFalse(pool.isAccountInScope(Y), "Y is explicitly out of this pool's scope");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(staker);
vm.expectRevert();
pool.withdraw();
}
function test_subsetScopedPoolPaysBonusEvenWhenPublishedScopeExcludesSiblingDriverY() external {
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
token.mint(bonusContributor, 50 * ONE);
vm.startPrank(bonusContributor);
token.approve(address(pool), 50 * ONE);
pool.contributeBonus(50 * ONE);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 3 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 before = token.balanceOf(staker);
vm.prank(staker);
pool.claimSurvived();
assertEq(token.balanceOf(staker) - before, 150 * ONE, "subset-scoped staker receives full bonus");
assertFalse(pool.isAccountInScope(Y), "published scope still excludes Y");
}
function test_scopeLockDoesNotIsolateStakersFromLaterAgreementAdditions() external {
// Rebuild with Y initially OUT of agreement scope so stakers truly join an X-only setup.
agreement.setContractInScope(Y, false);
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
// Lock pool scope while the agreement is still pre-risk.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
token.mint(bonusContributor, 1 * ONE);
vm.startPrank(bonusContributor);
token.approve(address(pool), 1 * ONE);
pool.contributeBonus(1 * ONE);
vm.stopPrank();
assertTrue(pool.scopeLocked(), "scope is now locked");
assertFalse(pool.isAccountInScope(Y), "pool never published Y");
assertFalse(agreement.isContractInScope(Y), "Y was not in the agreement when users joined");
// Underlying agreement later expands to Y anyway.
agreement.setContractInScope(Y, true);
assertTrue(agreement.isContractInScope(Y), "agreement added Y after scope lock");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(staker);
vm.expectRevert();
pool.withdraw();
}
function test_laterAddedYCanMechanicallyCorruptAndSweepXOnlyPool() external {
agreement.setContractInScope(Y, false);
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
token.mint(bonusContributor, 50 * ONE);
vm.startPrank(bonusContributor);
token.approve(address(pool), 50 * ONE);
pool.contributeBonus(50 * ONE);
vm.stopPrank();
// Lock scope before Y exists in the agreement.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
token.mint(address(0xCAFE), 1 * ONE);
vm.prank(address(0xCAFE));
token.approve(address(pool), 1 * ONE);
vm.prank(address(0xCAFE));
pool.contributeBonus(1 * ONE);
assertTrue(pool.scopeLocked(), "scope locked");
assertFalse(pool.isAccountInScope(Y), "pool never scoped Y");
assertFalse(agreement.isContractInScope(Y), "agreement did not scope Y at join time");
agreement.setContractInScope(Y, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "mechanical corrupted");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 151 * ONE, "full X-only pool swept");
}
}

Run:

cd repo && forge test --match-path 'test/poc/SubsetScopeSiblingLifecycle.t.sol' -vv

Verified result:

Ran 4 tests for test/poc/SubsetScopeSiblingLifecycle.t.sol:SubsetScopeSiblingLifecycleProbe
[PASS] test_laterAddedYCanMechanicallyCorruptAndSweepXOnlyPool()
[PASS] test_scopeLockDoesNotIsolateStakersFromLaterAgreementAdditions()
[PASS] test_subsetScopedPoolLocksWithdrawOnAgreementRiskThatCouldComeFromSiblingY()
[PASS] test_subsetScopedPoolPaysBonusEvenWhenPublishedScopeExcludesSiblingDriverY()
Suite result: ok. 4 passed; 0 failed; 0 skipped

The main test is test_scopeLockDoesNotIsolateStakersFromLaterAgreementAdditions.

It shows:

  1. The pool is scoped only to X.

  2. The staker joins while Y is not part of the agreement.

  3. The pool's own scope locks to [X].

  4. Y is added to the agreement later.

  5. The agreement enters UNDER_ATTACK.

  6. The X-only pool seals riskWindowStart.

  7. The X-only staker can no longer withdraw.

This proves the exit right is controlled by agreement-wide state, not by the pool's committed scope.

The claimExpired() / claimCorrupted() test is included only as a compounding illustration. The clean issue here is the immediate, scope-blind loss of the withdraw() exit right.

Recommended Mitigation

withdraw() should not permanently close a subset-scoped pool's exit right from raw agreement-wide active-risk state alone.

- IAttackRegistry.ContractState state = _observePoolState();
-
- if (
- riskWindowStart != 0
- || (
- state != IAttackRegistry.ContractState.NOT_DEPLOYED
- && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
- && state != IAttackRegistry.ContractState.ATTACK_REQUESTED
- )
- ) {
- revert WithdrawsDisabled();
- }
+ // Do not permanently close withdrawals for a subset-scoped pool from raw
+ // agreement-wide active-risk state unless the active-risk event is confirmed
+ // to involve one of this pool's committed scoped accounts.

Since the current registry state is agreement-wide and does not expose contract-level attribution, a safer design is to require moderator or scope-aware oracle confirmation before permanently sealing riskWindowStart and disabling withdraw() for subset-scoped stakers.

Support

FAQs

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

Give us feedback!