FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: low
Likelihood: medium

Factory can create and index pools for accounts not governed by the selected Safe Harbor agreement

Author Revealed upon completion

Root + Impact

Description

  • ConfidencePoolFactory.createPool() is expected to create official pool clones only for a valid Safe Harbor agreement and its scoped accounts.

  • The factory verifies that the selected agreement is valid and sponsor-owned, but it never checks whether each scoped account's BattleChain Binding Agreement is the selected agreement. The pool can therefore be officially created, emitted, and indexed for an account whose authoritative Safe Harbor agreement is different or absent.

function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
// @> This only proves the agreement was factory-created, not that it binds the scoped accounts.
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
// @> This only proves selected-agreement ownership.
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
pool = Clones.cloneDeterministic(poolImplementation, salt);
// @> accounts are forwarded without checking AttackRegistry.getAgreementForContract(account).
IConfidencePool(pool).initialize(..., agreement, ..., accounts);
function _replaceScope(address[] calldata accounts) internal {
...
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
// @> isContractInScope() is agreement-local scope membership, not Binding Agreement authority.
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
}

Risk

Likelihood:

  • The condition occurs when a sponsor owns a valid selected agreement whose local scope cache includes an account that is bound to another agreement, or has no Binding Agreement, in the AttackRegistry.

  • The upstream Safe Harbor interface exposes getAgreementForContract(account) specifically for contract-scoped Binding Agreement resolution, but the factory/pool creation path does not call it.

Impact:

  • The official factory emits PoolCreated and indexes a pool under an agreement that does not govern the scoped account.

  • Stakers and integrations can treat the factory-created pool as covering an account under the selected agreement even though BattleChain's authoritative Binding Agreement is different or absent.

Proof of Concept

Clone and pin the audited repository:

git clone https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools.git
cd 2026-07-bc-confidence-pools
git checkout 58e8ba4ce3f3277866e4926f3140e597f9554a1e
git submodule update --init --recursive

Save the following file as test/poc/BindingAgreementFactoryPoC.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract BindingAwareAttackRegistry {
mapping(address agreement => IAttackRegistry.ContractState state) internal stateByAgreement;
mapping(address account => address agreement) internal bindingAgreementByAccount;
function setAgreementState(address agreement, IAttackRegistry.ContractState state) external {
stateByAgreement[agreement] = state;
}
function setAgreementForContract(address account, address agreement) external {
bindingAgreementByAccount[account] = agreement;
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return stateByAgreement[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return bindingAgreementByAccount[account];
}
}
contract BindingAgreementFactoryPoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockSafeHarborRegistry internal registry;
BindingAwareAttackRegistry internal attackRegistry;
ConfidencePoolFactory internal factory;
MockAgreement internal selectedAgreement;
MockAgreement internal bindingAgreement;
address internal agreementOwner = makeAddr("agreementOwner");
address internal defaultModerator = makeAddr("defaultModerator");
address internal recovery = makeAddr("recovery");
address internal staker = makeAddr("staker");
function setUp() public {
token = new MockERC20();
registry = new MockSafeHarborRegistry();
attackRegistry = new BindingAwareAttackRegistry();
selectedAgreement = new MockAgreement(agreementOwner);
bindingAgreement = new MockAgreement(makeAddr("bindingAgreementOwner"));
selectedAgreement.setContractInScope(SCOPE_ACCOUNT, true);
registry.setAgreementValid(address(selectedAgreement), true);
registry.setAgreementValid(address(bindingAgreement), true);
registry.setAttackRegistry(address(attackRegistry));
ConfidencePoolFactory impl = new ConfidencePoolFactory();
ConfidencePool poolImplementation = new ConfidencePool();
ERC1967Proxy proxy = new ERC1967Proxy(
address(impl),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(registry), address(poolImplementation), defaultModerator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function testCreatePoolAcceptsAccountBoundToDifferentAgreementAndUsesSelectedAgreementState() external {
attackRegistry.setAgreementForContract(SCOPE_ACCOUNT, address(bindingAgreement));
attackRegistry.setAgreementState(address(selectedAgreement), IAttackRegistry.ContractState.ATTACK_REQUESTED);
attackRegistry.setAgreementState(address(bindingAgreement), IAttackRegistry.ContractState.CORRUPTED);
vm.prank(agreementOwner);
address created = factory.createPool(
address(selectedAgreement), address(token), block.timestamp + 31 days, ONE, recovery, _scope()
);
assertEq(
attackRegistry.getAgreementForContract(SCOPE_ACCOUNT),
address(bindingAgreement),
"test precondition: account Binding Agreement differs"
);
assertEq(ConfidencePool(created).agreement(), address(selectedAgreement), "pool stores selected agreement");
assertTrue(ConfidencePool(created).isAccountInScope(SCOPE_ACCOUNT), "selected scope accepted");
token.mint(staker, ONE);
vm.startPrank(staker);
token.approve(created, ONE);
ConfidencePool(created).stake(ONE);
vm.stopPrank();
assertEq(token.balanceOf(created), ONE, "stake used selected agreement state, not Binding Agreement state");
assertEq(factory.poolCountByAgreement(address(selectedAgreement)), 1, "factory registered mismatched pool");
}
function testCreatePoolAcceptsAccountWithNoBindingAgreement() external {
attackRegistry.setAgreementForContract(SCOPE_ACCOUNT, address(0));
attackRegistry.setAgreementState(address(selectedAgreement), IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.prank(agreementOwner);
address created = factory.createPool(
address(selectedAgreement), address(token), block.timestamp + 31 days, ONE, recovery, _scope()
);
assertEq(
attackRegistry.getAgreementForContract(SCOPE_ACCOUNT),
address(0),
"test precondition: account has no Binding Agreement"
);
assertEq(ConfidencePool(created).agreement(), address(selectedAgreement), "pool stores selected agreement");
assertTrue(ConfidencePool(created).isAccountInScope(SCOPE_ACCOUNT), "selected scope accepted");
}
function _scope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
}
}

Run:

forge test --match-path test/poc/BindingAgreementFactoryPoC.t.sol -vvv

Expected output summary:

Ran 2 tests for test/poc/BindingAgreementFactoryPoC.t.sol
[PASS] testCreatePoolAcceptsAccountBoundToDifferentAgreementAndUsesSelectedAgreementState()
[PASS] testCreatePoolAcceptsAccountWithNoBindingAgreement()
2 tests passed, 0 failed, 0 skipped

Recommended Mitigation

for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ if (IAttackRegistry(attackRegistry).getAgreementForContract(account) != agreement) {
+ revert AccountNotInAgreementScope(account);
+ }
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!