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

`isAgreementValid` does not prove that the agreement binds the pool scope

Author Revealed upon completion

Root + Impact

Description

The factory can publish a pool whose agreement is factory-deployed but is not the Binding Agreement for any account in the pool's advertised scope.

A pool is expected to follow the BattleChain agreement that governs its scoped contracts. The factory therefore checks the agreement before creating and indexing the pool, while the pool validates every supplied scope account during initialization.

Both validations are self-referential. isAgreementValid(agreement) proves only that agreement was deployed by the configured AgreementFactory. _replaceScope() then asks that same agreement whether each account appears in its local scope cache. Neither check asks AttackRegistry.getAgreementForContract(account) which agreement actually binds the account.

Because agreement creation is permissionless, an attacker can create a factory-valid shadow agreement that lists an unbound target account. After the legitimate protocol binds that account to another agreement, the shadow agreement's scope cache remains true. The attacker, as owner of the shadow agreement, passes the factory ownership check and creates a factory-indexed pool for it. The pool then reads lifecycle state for the shadow agreement, not the account's Binding Agreement; for example, it can accept capital under shadow NOT_DEPLOYED state even when the account's actual agreement is already terminal.

src/ConfidencePoolFactory.sol
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();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement(); // @> Factory provenance only.
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
// @> No check binds `accounts` to `agreement` in AttackRegistry before the clone is indexed.
// ... clone deployment and initialization omitted ...
emit PoolCreated(
agreement, pool, stakeToken, expiry, minStake, recoveryAddress, defaultOutcomeModerator, address(safeHarborRegistry)
);
}
src/ConfidencePool.sol
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
// ... old scope clearing omitted ...
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) { // @> Consults the supplied agreement only.
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}

Risk

Likelihood:

  • The shadow-agreement owner must list the target before it is actively linked elsewhere, then attract users who trust factory provenance and the published scope without independently resolving the Binding Agreement.

  • Canonical AttackRegistry conflict checks prevent two active agreements from binding the same account, limiting this to an unregistered or otherwise non-binding shadow agreement rather than arbitrary control of an active victim agreement.

Impact:

  • Deposits and bonus contributions can be accepted under lifecycle state unrelated to the scoped contracts, invalidating the pool's advertised confidence signal and withdrawal/resolution assumptions.

  • Users who treat a factory-created pool and isAgreementValid == true as proof of binding can place an unbounded amount of capital into a misleading configuration; actual loss still requires that consumer-verification failure.

Proof of Concept

Create test/audit/CP024AgreementBinding.t.sol with the following contents:

// 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 {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.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 BindingAwareAttackRegistry {
mapping(address agreement => IAttackRegistry.ContractState state) internal _state;
mapping(address account => address agreement) internal _binding;
function setAgreementState(address agreement, IAttackRegistry.ContractState state) external {
_state[agreement] = state;
}
function setAgreementForContract(address account, address agreement) external {
_binding[account] = agreement;
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return _state[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return _binding[account];
}
}
contract CP024AgreementBindingTest is Test {
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
address internal staker = makeAddr("staker");
address internal recovery = makeAddr("recovery");
address internal moderator = makeAddr("moderator");
function test_FactoryAcceptsAgreementThatDoesNotBindThePoolScope() external {
vm.warp(1_750_000_000);
MockERC20 token = new MockERC20();
MockAgreement bindingAgreement = new MockAgreement(makeAddr("protocolOwner"));
MockAgreement shadowAgreement = new MockAgreement(address(this));
shadowAgreement.setContractInScope(SCOPE_ACCOUNT, true);
BindingAwareAttackRegistry attackRegistry = new BindingAwareAttackRegistry();
attackRegistry.setAgreementForContract(SCOPE_ACCOUNT, address(bindingAgreement));
attackRegistry.setAgreementState(address(bindingAgreement), IAttackRegistry.ContractState.PRODUCTION);
attackRegistry.setAgreementState(address(shadowAgreement), IAttackRegistry.ContractState.NOT_DEPLOYED);
MockSafeHarborRegistry safeHarborRegistry = new MockSafeHarborRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(shadowAgreement), true);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(poolImplementation), moderator)
)
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
address[] memory accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
address poolAddress = factory.createPool(
address(shadowAgreement), address(token), block.timestamp + 31 days, 1 ether, recovery, accounts
);
ConfidencePool pool = ConfidencePool(poolAddress);
assertEq(pool.agreement(), address(shadowAgreement));
assertEq(attackRegistry.getAgreementForContract(SCOPE_ACCOUNT), address(bindingAgreement));
assertTrue(pool.isAccountInScope(SCOPE_ACCOUNT));
// The pool follows the unregistered shadow agreement and accepts capital even though the
// scoped account's actual binding agreement is already terminal.
token.mint(staker, 10 ether);
vm.prank(staker);
token.approve(poolAddress, 10 ether);
vm.prank(staker);
pool.stake(10 ether);
assertEq(pool.eligibleStake(staker), 10 ether);
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP024AgreementBinding.t.sol -vv.

  2. Confirm that the test passes, proving that the pool accepts stake while its scoped account is bound to a different agreement.

Recommended Mitigation

Permit pool deployment during pre-registration staging if required, but reject value inflows until every pool-scope account is bound to the pool's stored agreement. Centralize this check so both stake and bonus paths enforce the same invariant.

src/ConfidencePool.sol
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
+ _assertScopeBoundToAgreement();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
// ... unchanged transfer and accounting ...
emit Staked(msg.sender, received);
}
function contributeBonus(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
+ _assertScopeBoundToAgreement();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
// ... unchanged transfer and accounting ...
emit BonusContributed(msg.sender, received);
}
+function _assertScopeBoundToAgreement() internal view {
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ for (uint256 i; i < _scopeAccounts.length; ++i) {
+ if (IAttackRegistry(attackRegistry).getAgreementForContract(_scopeAccounts[i]) != agreement) {
+ revert AccountNotBoundToAgreement(_scopeAccounts[i]);
+ }
+ }
+}

If deposits before AttackRegistry registration are an explicit product requirement, expose that state as unverified and require callers to opt in through calldata; isAgreementValid and isContractInScope should not be presented as binding checks.

Support

FAQs

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

Give us feedback!