The pool has a mechanical fallback for a permanently unavailable moderator. After expiry + MODERATOR_CORRUPTED_GRACE, anyone can finalize the pool as bad faith CORRUPTED when the upstream registry reports CORRUPTED and riskWindowStart was observed. The full pool balance is then swept to recoveryAddress.
In the integrated BattleChain flow, the pool sponsor is also the agreement owner. The factory requires the current agreement owner to create the pool, and the upstream AttackRegistry installs the agreement owner as the agreement's attackModerator at registration. That role can call markCorrupted() from UNDER_ATTACK or PROMOTION_REQUESTED without confirmation from the pool moderator or registry moderator.
This is distinct from the documented scope blind fallback. The accepted design risk covers a real agreement-level corruption that may be out of the pool's local scope. Here, the payout recipient can manufacture the upstream CORRUPTED state without any on-chain independent confirmation. The documented sponsor trust surface covers recoveryAddress, expiry, and scope, but it does not grant the sponsor outcome authority. The design instead treats the pool DAO moderator as the corruption decision maker.
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 {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract ConfidencePoolFactoryAttackModeratorDriftTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePoolFactory internal factory;
address internal sponsor = makeAddr("sponsor");
address internal moderator = makeAddr("moderator");
address internal registryModerator = makeAddr("registryModerator");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
address internal bonusContributor = makeAddr("bonusContributor");
function setUp() external {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(sponsor);
agreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), moderator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function testSponsorRegistrationAuthorityCanDrivePostGraceRecoverySweep() external {
uint256 expiry = block.timestamp + 31 days;
address[] memory accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
vm.prank(sponsor);
address poolAddress = factory.createPool(address(agreement), address(token), expiry, ONE, sponsor, accounts);
ConfidencePool pool = ConfidencePool(poolAddress);
assertEq(pool.owner(), sponsor, "factory assigns pool ownership to agreement owner");
assertEq(pool.recoveryAddress(), sponsor, "sponsor controls recovery destination from creation");
vm.prank(sponsor);
attackRegistry.requestUnderAttack(address(agreement));
assertEq(
attackRegistry.getAttackModerator(address(agreement)),
sponsor,
"registration derives attack moderator from agreement owner"
);
vm.prank(registryModerator);
attackRegistry.approveAttack(address(agreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK),
"agreement is in active risk"
);
_stake(pool, alice, 100 * ONE);
_stake(pool, bob, 50 * ONE);
_contributeBonus(pool, bonusContributor, 30 * ONE);
assertGt(pool.riskWindowStart(), 0, "stake observed active risk");
vm.prank(sponsor);
attackRegistry.markCorrupted(address(agreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreement))),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"sponsor can mark corrupted through derived attack moderator role"
);
vm.warp(expiry + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(sponsor);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted(), "mechanical corrupted resolution is final");
uint256 sponsorBefore = token.balanceOf(sponsor);
vm.prank(sponsor);
pool.claimCorrupted();
assertEq(token.balanceOf(sponsor) - sponsorBefore, 180 * ONE);
assertEq(token.balanceOf(address(pool)), 0);
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(bob), 0);
}
function _stake(ConfidencePool pool, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(ConfidencePool pool, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
contract MockAttackRegistry {
IAttackRegistry.ContractState internal state;
address internal moderator;
error UnauthorizedAttackModerator(address caller);
error InvalidState(IAttackRegistry.ContractState state);
function setAgreementState(IAttackRegistry.ContractState nextState) external {
state = nextState;
}
function setAttackModerator(address nextModerator) external {
moderator = nextModerator;
}
function requestUnderAttack(address agreement) external {
moderator = IAgreement(agreement).owner();
state = IAttackRegistry.ContractState.ATTACK_REQUESTED;
}
function approveAttack(address) external {
if (state != IAttackRegistry.ContractState.ATTACK_REQUESTED) revert InvalidState(state);
state = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function getAgreementState(address) external view returns (IAttackRegistry.ContractState) {
return state;
}
function getAttackModerator(address) external view returns (address) {
return moderator;
}
function markCorrupted(address) external {
if (msg.sender != moderator) revert UnauthorizedAttackModerator(msg.sender);
if (
state != IAttackRegistry.ContractState.UNDER_ATTACK
&& state != IAttackRegistry.ContractState.PROMOTION_REQUESTED
) {
revert InvalidState(state);
}
state = IAttackRegistry.ContractState.CORRUPTED;
}
}
If the registry cannot expose independent corruption provenance, use a neutral escrow or a principal returning fallback instead of sending the full pool to the sponsor-controlled recovery address.