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.
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.
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 {
agreement.setContractInScope(Y, false);
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
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");
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();
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");
}
}
This proves the exit right is controlled by agreement-wide state, not by the pool's committed scope.
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.