ConfidencePool._replaceScope validates each scope account against the agreement's isContractInScope, which is a self-declared mapping populated by the agreement owner. It does not cross-reference AttackRegistry.getAgreementForContract, the authoritative binding. This means a factory-created agreement can advertise any BattleChain address in its scope regardless of whether the AttackRegistry recognizes that agreement as the binding agreement for the address.
Reason 1: Exploitation requires the vanity agreement to be registered in AttackRegistry via requestUnderAttack, which demands a bond and DAO approval (approveAttack). The DAO would reject a vanity agreement attempting to register contracts already bound to an honest agreement.
Reason 2: Without AttackRegistry registration, the vanity agreement stays in NOT_DEPLOYED state, preventing flagOutcome from reaching CORRUPTED and blocking the drain path entirely.
Impact 1: A pool could publish a scope list that includes contracts not governed by its agreement, misleading stakers about coverage boundaries.
Impact 2: If the DAO misconfigures or colludes to approve a vanity registration, the missing binding check allows the pool to accept unbound accounts, enabling a drain via the CORRUPTED → claimCorrupted path.
Added setAgreementForContract and getAgreementForContract to the MockAttackRegistry.sol — these are needed to demonstrate the binding mismatch.
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 {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract VanityScopeBindingBypassTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarbor;
ConfidencePoolFactory internal factory;
ConfidencePool internal poolImpl;
MockAgreement internal vanityAgreement;
MockAgreement internal bindingAgreement;
address internal famousProtocol = makeAddr("FamousProtocol");
address internal attackerToy = makeAddr("AttackerToy");
address internal attacker = makeAddr("attacker");
address internal attackerRecovery = makeAddr("attackerRecovery");
address internal moderator = makeAddr("moderator");
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarbor = new MockSafeHarborRegistry();
poolImpl = new ConfidencePool();
vanityAgreement = new MockAgreement(attacker);
bindingAgreement = new MockAgreement(makeAddr("honestProtocolOwner"));
vanityAgreement.setContractInScope(famousProtocol, true);
vanityAgreement.setContractInScope(attackerToy, true);
attackRegistry.setAgreementForContract(famousProtocol, address(bindingAgreement));
safeHarbor.setAttackRegistry(address(attackRegistry));
safeHarbor.setAgreementValid(address(vanityAgreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarbor), address(poolImpl), moderator)
)
)
)
);
factory.setStakeTokenAllowed(address(token), true);
}
function _vanityScope() internal view returns (address[] memory accounts) {
accounts = new address[](2);
accounts[0] = famousProtocol;
accounts[1] = attackerToy;
}
function test_PoolAcceptsUnboundAccount_ScopeValidationGap() external {
assertEq(
attackRegistry.getAgreementForContract(famousProtocol),
address(bindingAgreement),
"FamousProtocol Binding is the honest protocol agreement"
);
assertTrue(vanityAgreement.isContractInScope(famousProtocol), "vanity self-declares FamousProtocol");
assertTrue(
attackRegistry.getAgreementForContract(famousProtocol) != address(vanityAgreement),
"FamousProtocol is NOT Bound to the vanity agreement"
);
uint256 expiry = block.timestamp + 31 days;
vm.prank(attacker);
address poolAddr = factory.createPool(
address(vanityAgreement), address(token), expiry, ONE, attackerRecovery, _vanityScope()
);
ConfidencePool pool = ConfidencePool(poolAddr);
assertTrue(pool.isAccountInScope(famousProtocol), "pool accepted unbound FamousProtocol into published scope");
assertTrue(pool.isAccountInScope(attackerToy));
}
function test_PoolAcceptsSingleUnboundAccount() external {
address[] memory scope = new address[](1);
scope[0] = famousProtocol;
assertTrue(vanityAgreement.isContractInScope(famousProtocol));
assertTrue(attackRegistry.getAgreementForContract(famousProtocol) != address(vanityAgreement));
vm.prank(attacker);
address poolAddr =
factory.createPool(address(vanityAgreement), address(token), block.timestamp + 31 days, ONE, attackerRecovery, scope);
assertTrue(ConfidencePool(poolAddr).isAccountInScope(famousProtocol));
}
function test_PoolAcceptsZeroBindingAccount() external {
address ghost = makeAddr("NeverLinkedContract");
vanityAgreement.setContractInScope(ghost, true);
address[] memory scope = new address[](1);
scope[0] = ghost;
assertEq(attackRegistry.getAgreementForContract(ghost), address(0));
vm.prank(attacker);
address poolAddr =
factory.createPool(address(vanityAgreement), address(token), block.timestamp + 31 days, ONE, attackerRecovery, scope);
assertTrue(ConfidencePool(poolAddr).isAccountInScope(ghost));
}
function test_Reference_BindingCheckWouldBlockVanityScope() external view {
address binder = attackRegistry.getAgreementForContract(famousProtocol);
address vanity = address(vanityAgreement);
assertTrue(binder != vanity, "binding != pool.agreement, a check would catch this");
assertTrue(vanityAgreement.isContractInScope(famousProtocol), "self-declare alone is not Binding");
}
}