The pool locks its local list once the attack lifecycle begins, so depositors remain covered by exactly the contracts they selected. The factory only verifies that each contract is in the selected agreement when the pool is created.
The problem is that the pool’s local scope and the agreement’s scope can later diverge. For example, C can initially belong to Agreement A and be locked into the pool’s scope. After A’s commitment period expires, C can be removed from A and placed under Agreement B. The pool still records C as covered, but it continues reading only A’s state.
If C is later corrupted under B while A remains active, the pool cannot recognize the corruption. The moderator’s CORRUPTED resolution reverts, and expiry incorrectly resolves the pool as EXPIRED or SURVIVED, despite C being one of the pool’s permanently locked covered contracts.
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 {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract BindingMigrationAttackRegistry {
uint256 internal constant MIN_COMMITMENT = 7 days;
mapping(address => IAttackRegistry.ContractState) private states;
mapping(address => address) private bindings;
function requestUnderAttack(address agreement, address[] calldata accounts) external {
require(
IBindingMigrationAgreement(agreement).getCantChangeUntil()
>= block.timestamp + MIN_COMMITMENT
);
for (uint256 i; i < accounts.length; ++i) {
require(bindings[accounts[i]] == address(0));
bindings[accounts[i]] = agreement;
}
states[agreement] = IAttackRegistry.ContractState.ATTACK_REQUESTED;
}
function approveAttack(address agreement) external {
require(
states[agreement] == IAttackRegistry.ContractState.ATTACK_REQUESTED
);
states[agreement] = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function markCorrupted(address agreement) external {
IAttackRegistry.ContractState state = states[agreement];
require(
state == IAttackRegistry.ContractState.UNDER_ATTACK
|| state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
);
states[agreement] = IAttackRegistry.ContractState.CORRUPTED;
}
function getAgreementState(address agreement)
external
view
returns (IAttackRegistry.ContractState)
{
return states[agreement];
}
function unregisterContractForExistingAgreement(
address account,
address agreement
) external {
if (bindings[account] == agreement) {
delete bindings[account];
}
}
function getAgreementForContract(address account)
external
view
returns (address)
{
return bindings[account];
}
}
contract BindingMigrationAgreement {
address public owner;
uint256 public cantChangeUntil;
BindingMigrationAttackRegistry internal registry;
constructor(
address owner_,
BindingMigrationAttackRegistry registry_,
uint256 cantChangeUntil_
) {
owner = owner_;
registry = registry_;
cantChangeUntil = cantChangeUntil_;
}
function getCantChangeUntil() external view returns (uint256) {
return cantChangeUntil;
}
function removeAccount(address account) external {
require(msg.sender == owner);
require(block.timestamp >= cantChangeUntil);
registry.unregisterContractForExistingAgreement(
account,
address(this)
);
}
}
contract ConfidencePoolBindingMigrationTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant C = address(0xC0FFEE);
address internal protocolOwner = makeAddr("protocolOwner");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
address internal alice = makeAddr("alice");
MockERC20 internal token;
MockSafeHarborRegistry internal safeHarborRegistry;
BindingMigrationAttackRegistry internal attackRegistry;
BindingMigrationAgreement internal agreementA;
BindingMigrationAgreement internal agreementB;
ConfidencePool internal pool;
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
safeHarborRegistry = new MockSafeHarborRegistry();
attackRegistry = new BindingMigrationAttackRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
agreementA = new BindingMigrationAgreement(
protocolOwner,
attackRegistry,
BASE_TIMESTAMP + 7 days
);
agreementB = new BindingMigrationAgreement(
protocolOwner,
attackRegistry,
BASE_TIMESTAMP + 14 days
);
safeHarborRegistry.setAgreementValid(address(agreementA), true);
safeHarborRegistry.setAgreementValid(address(agreementB), true);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = C;
pool.initialize(
address(agreementA),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
}
function testBindingMigrationMakesBreachUnresolvable() external {
address[] memory aScope = new address[](1);
aScope[0] = C;
attackRegistry.requestUnderAttack(address(agreementA), aScope);
attackRegistry.approveAttack(address(agreementA));
pool.pokeRiskWindow();
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
vm.warp(BASE_TIMESTAMP + 7 days);
vm.prank(protocolOwner);
agreementA.removeAccount(C);
assertEq(
attackRegistry.getAgreementForContract(C),
address(0)
);
assertTrue(pool.isAccountInScope(C));
address[] memory bScope = new address[](1);
bScope[0] = C;
attackRegistry.requestUnderAttack(address(agreementB), bScope);
attackRegistry.approveAttack(address(agreementB));
attackRegistry.markCorrupted(address(agreementB));
assertEq(
attackRegistry.getAgreementForContract(C),
address(agreementB)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementA))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
attacker
);
vm.warp(pool.expiry());
pool.claimExpired();
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.EXPIRED)
);
assertTrue(pool.claimsStarted());
}
}
interface IBindingMigrationAgreement {
function getCantChangeUntil()
external
view
returns (uint256);
}
Require the agreement’s commitment window to last through the pool expiry, and enforce the same invariant whenever expiry changes.