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

Pool creation binds to any factory-valid agreement, not the scoped contract’s real Binding Agreement, enabling wrong withdraw and wrong expiry outcomes

Author Revealed upon completion

Root + Impact

Description

  • The pool is expected to track the BattleChain attack state for the contracts it protects. For a scoped contract, the authoritative agreement is its real Binding Agreement as resolved by AttackRegistry.getAgreementForContract(...).

  • The issue is that pool creation and runtime state checks bind the pool to the user-provided agreement as long as it is factory-valid and self-reports the scoped account. The code does not verify that this agreement is actually the Binding Agreement for the scoped contract, so the pool can read state from agreement A while the protected contract is legally covered by agreement B.

    function createPool(
    address agreement,
    address stakeToken,
    uint256 expiry,
    uint256 minStake,
    address recoveryAddress,
    address[] calldata accounts
    ) external returns (address pool) {
    if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
    if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
    pool = Clones.cloneDeterministic(poolImplementation, salt);
    IConfidencePool(pool).initialize(
    agreement,
    stakeToken,
    address(safeHarborRegistry),
    defaultOutcomeModerator,
    expiry,
    minStake,
    recoveryAddress,
    msg.sender,
    accounts
    );
    }
    function _replaceScope(address[] calldata accounts) internal {
    for (uint256 i; i < accounts.length; ++i) {
    address account = accounts[i];
    if (!IAgreement(agreement).isContractInScope(account)) {
    revert AccountNotInAgreementScope(account);
    }
    _scopeAccounts.push(account);
    isAccountInScope[account] = true;
    }
    }
    function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
    address attackRegistry = safeHarborRegistry.getAttackRegistry();
    if (attackRegistry == address(0)) revert InvalidAgreement();
    return IAttackRegistry(attackRegistry).getAgreementState(agreement);
    }

    isAgreementValid(agreement) only proves that the agreement has valid factory provenance. IAgreement(agreement).isContractInScope(account) only proves that the agreement claims the account in its own scope. Neither check proves that the agreement is the scoped contract’s real Binding Agreement.


Risk

Likelihood:

  • A pool is created with a factory-valid agreement A that includes the scoped contract, while AttackRegistry.getAgreementForContract(scopedContract) resolves the contract’s Binding Agreement to agreement B.

  • Agreement A and Binding Agreement B are in different attack states, such as A being pre-risk while B is UNDER_ATTACK or CORRUPTED.

Impact:

  • Withdrawals can remain open during the real Binding Agreement’s attack period because the pool reads the state of the wrong agreement.

  • Expiry or survival paths can return stake and bonus even when the real Binding Agreement is already CORRUPTED.

Proof of Concept

// 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 {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockBindingAwareAttackRegistry} from "test/mocks/MockBindingAwareAttackRegistry.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract BindingAgreementMismatchTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockSafeHarborRegistry internal registry;
MockBindingAwareAttackRegistry internal attackRegistry;
MockAgreement internal agreementA;
MockAgreement internal agreementB;
ConfidencePoolFactory internal factory;
address internal moderator = makeAddr("moderator");
address internal agreementOwner = makeAddr("agreementOwner");
address internal recovery = makeAddr("recovery");
address internal staker = makeAddr("staker");
function setUp() public {
token = new MockERC20();
registry = new MockSafeHarborRegistry();
attackRegistry = new MockBindingAwareAttackRegistry();
agreementA = new MockAgreement(agreementOwner);
agreementB = new MockAgreement(agreementOwner);
agreementA.setContractInScope(SCOPE_ACCOUNT, true);
agreementB.setContractInScope(SCOPE_ACCOUNT, true);
registry.setAttackRegistry(address(attackRegistry));
registry.setAgreementValid(address(agreementA), true);
registry.setAgreementValid(address(agreementB), true);
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (address(registry), address(implementation), moderator))
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function testBindingAgreementStateDisablesWithdrawEvenIfConfiguredAgreementLooksPreRisk() external {
address[] memory accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
// The scoped contract's real Binding Agreement is B, but the pool is created against A.
attackRegistry.setBindingAgreement(SCOPE_ACCOUNT, address(agreementB));
attackRegistry.setAgreementState(address(agreementA), IAttackRegistry.ContractState.ATTACK_REQUESTED);
attackRegistry.setAgreementState(address(agreementB), IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(agreementOwner);
address poolAddr =
factory.createPool(address(agreementA), address(token), block.timestamp + 31 days, ONE, recovery, accounts);
ConfidencePool pool = ConfidencePool(poolAddr);
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
// The scoped contract's Binding Agreement B is already UNDER_ATTACK, so withdraw must be
// disabled even though the configured agreement A still looks pre-risk.
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.stopPrank();
assertEq(
attackRegistry.getAgreementForContract(SCOPE_ACCOUNT), address(agreementB), "binding agreement differs"
);
}
function testBindingAgreementCorruptionBlocksStakeAndPreventsWrongExpiryPath() external {
address[] memory accounts = new address[](1);
accounts[0] = SCOPE_ACCOUNT;
attackRegistry.setBindingAgreement(SCOPE_ACCOUNT, address(agreementB));
attackRegistry.setAgreementState(address(agreementA), IAttackRegistry.ContractState.ATTACK_REQUESTED);
attackRegistry.setAgreementState(address(agreementB), IAttackRegistry.ContractState.CORRUPTED);
vm.prank(agreementOwner);
address poolAddr =
factory.createPool(address(agreementA), address(token), block.timestamp + 31 days, ONE, recovery, accounts);
ConfidencePool pool = ConfidencePool(poolAddr);
// With the real Binding Agreement already CORRUPTED, staking is closed immediately.
token.mint(staker, 100 * ONE);
vm.startPrank(staker);
token.approve(address(pool), 100 * ONE);
vm.expectRevert(IConfidencePool.StakingClosed.selector);
pool.stake(100 * ONE);
vm.stopPrank();
assertEq(
attackRegistry.getAgreementForContract(SCOPE_ACCOUNT), address(agreementB), "binding agreement differs"
);
}
}

Recommended Mitigation

function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
- return IAttackRegistry(attackRegistry).getAgreementState(agreement);
+ address resolvedAgreement = _resolveBindingAgreement(attackRegistry);
+ return IAttackRegistry(attackRegistry).getAgreementState(resolvedAgreement);
}
+function _resolveBindingAgreement(address attackRegistry) internal view returns (address resolvedAgreement) {
+ resolvedAgreement = agreement;
+
+ address[] memory accounts = _scopeAccounts;
+ for (uint256 i; i < accounts.length; ++i) {
+ (bool ok, bytes memory data) = attackRegistry.staticcall(
+ abi.encodeCall(IAttackRegistry.getAgreementForContract, (accounts[i]))
+ );
+
+ // Best-effort fallback for mocks or legacy registries that do not expose the selector.
+ if (!ok || data.length < 32) continue;
+
+ address bindingAgreement = abi.decode(data, (address));
+ if (bindingAgreement == address(0)) continue;
+
+ if (resolvedAgreement != agreement && resolvedAgreement != bindingAgreement) {
+ revert InvalidAgreement();
+ }
+
+ resolvedAgreement = bindingAgreement;
+ }
+}

Support

FAQs

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

Give us feedback!