function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
...
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
...
}
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}
function _replaceScope(address[] calldata accounts) internal {
...
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
}
This PoC stages two valid agreements that both list the same scoped account, while the AttackRegistry binding for that account points to the second agreement. The pool is created with the non-binding agreement, then the binding agreement is marked CORRUPTED and the non-binding agreement is marked PRODUCTION. The test proves impact by showing flagOutcome(CORRUPTED, ...) reverts, SURVIVED succeeds, and the staker withdraws principal even though the scoped account's Binding Agreement is corrupted.
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 {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract SelectiveAttackRegistry {
mapping(address agreement => IAttackRegistry.ContractState state) internal states;
mapping(address account => address agreement) internal bindingAgreement;
function setAgreementState(address agreement, IAttackRegistry.ContractState state) external {
states[agreement] = state;
}
function setAgreementForContract(address account, address agreement) external {
bindingAgreement[account] = agreement;
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return states[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return bindingAgreement[account];
}
}
contract ValidFindingsPoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
address internal sponsor = makeAddr("sponsor");
address internal moderator = makeAddr("moderator");
address internal staker = makeAddr("staker");
address internal attacker = makeAddr("attacker");
address internal recovery = makeAddr("recovery");
address internal victim = makeAddr("victim");
address internal whale = makeAddr("whale");
function _scope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
}
function _deployFactory(MockSafeHarborRegistry registry, ConfidencePool implementation)
internal
returns (ConfidencePoolFactory factory)
{
ConfidencePoolFactory impl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(impl),
abi.encodeCall(ConfidencePoolFactory.initialize, (address(registry), address(implementation), moderator))
);
factory = ConfidencePoolFactory(address(proxy));
}
function testPoC_NonBindingAgreementPoolCannotSettleBindingCorruption() external {
MockERC20 token = new MockERC20();
MockSafeHarborRegistry safeHarborRegistry = new MockSafeHarborRegistry();
SelectiveAttackRegistry attackRegistry = new SelectiveAttackRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
MockAgreement nonBindingAgreement = new MockAgreement(sponsor);
MockAgreement bindingAgreement = new MockAgreement(sponsor);
nonBindingAgreement.setContractInScope(SCOPE_ACCOUNT, true);
bindingAgreement.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAgreementValid(address(nonBindingAgreement), true);
safeHarborRegistry.setAgreementValid(address(bindingAgreement), true);
attackRegistry.setAgreementForContract(SCOPE_ACCOUNT, address(bindingAgreement));
attackRegistry.setAgreementState(address(nonBindingAgreement), IAttackRegistry.ContractState.NEW_DEPLOYMENT);
attackRegistry.setAgreementState(address(bindingAgreement), IAttackRegistry.ContractState.CORRUPTED);
ConfidencePoolFactory factory = _deployFactory(safeHarborRegistry, new ConfidencePool());
factory.setStakeTokenAllowed(address(token), true);
vm.prank(sponsor);
ConfidencePool pool = ConfidencePool(
factory.createPool(
address(nonBindingAgreement), address(token), block.timestamp + 31 days, ONE, recovery, _scope()
)
);
token.mint(staker, ONE);
vm.startPrank(staker);
token.approve(address(pool), ONE);
pool.stake(ONE);
vm.stopPrank();
attackRegistry.setAgreementState(address(nonBindingAgreement), IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(staker);
pool.claimSurvived();
assertEq(token.balanceOf(staker), ONE, "staker recovers principal despite binding agreement corruption");
assertEq(token.balanceOf(recovery), 0, "corrupted recovery path was never reached");
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.SURVIVED),
"pool settled from non-binding agreement state"
);
assertEq(
attackRegistry.getAgreementForContract(SCOPE_ACCOUNT),
address(bindingAgreement),
"scope account was bound elsewhere"
);
}
}
Validate each scoped account against the AttackRegistry Binding Agreement before storing the scope. This keeps the pool-local commitment, while ensuring the agreement used for settlement is the agreement that actually governs the scoped account.