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

Pool scope accepts accounts whose Binding Agreement differs from the selected agreement

Author Revealed upon completion

Root + Impact

Description

  • Pool scope is expected to describe the accounts covered by the selected Safe Harbor agreement used by the pool.

  • initialize() and setPoolScope() accept accounts using only the selected agreement's isContractInScope(account) result. That check is agreement-local scope membership and does not prove the selected agreement is the account's BattleChain Binding Agreement, so a pool can commit scope entries that are governed by another agreement in the AttackRegistry.

function initialize(...) external initializer {
...
if (!IBattleChainSafeHarborRegistry(safeHarborRegistry_).isAgreementValid(agreement_)) {
revert InvalidAgreement();
}
// @> The selected agreement is stored after validity only; no per-account binding check is performed.
agreement = agreement_;
...
_replaceScope(accounts);
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
// @> Replacements use the same selected-agreement-only scope validation.
_replaceScope(accounts);
}
function _replaceScope(address[] calldata accounts) internal {
...
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
// @> Missing check: AttackRegistry.getAgreementForContract(account) == agreement.
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
}

Risk

Likelihood:

  • The condition occurs when multiple valid Safe Harbor agreements include the same account in local scope, while the AttackRegistry Binding Agreement for that account points to another agreement.

  • Both initial pool setup and owner-driven scope replacement use the same selected-agreement-only validation path.

Impact:

  • isAccountInScope(account) can return true for an account not governed by the pool's selected agreement.

  • Users and integrations can rely on a pool scope that disagrees with BattleChain's authoritative Binding Agreement lookup, causing incorrect pool presentation and downstream state assumptions.

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/BindingAgreementScopePoC.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract BindingAwareAttackRegistryForScopePoC {
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 BindingAgreementScopePoC is Test {
uint256 internal constant ONE = 1e18;
address internal constant INITIAL_ACCOUNT = address(0xC0FFEE);
address internal constant REPLACEMENT_ACCOUNT = address(0xBEEF);
MockERC20 internal token;
MockSafeHarborRegistry internal registry;
BindingAwareAttackRegistryForScopePoC internal attackRegistry;
MockAgreement internal selectedAgreement;
MockAgreement internal bindingAgreement;
ConfidencePool internal pool;
address internal owner = makeAddr("owner");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal staker = makeAddr("staker");
function setUp() public {
token = new MockERC20();
registry = new MockSafeHarborRegistry();
attackRegistry = new BindingAwareAttackRegistryForScopePoC();
selectedAgreement = new MockAgreement(owner);
bindingAgreement = new MockAgreement(makeAddr("bindingAgreementOwner"));
selectedAgreement.setContractInScope(INITIAL_ACCOUNT, true);
selectedAgreement.setContractInScope(REPLACEMENT_ACCOUNT, true);
registry.setAgreementValid(address(selectedAgreement), true);
registry.setAgreementValid(address(bindingAgreement), true);
registry.setAttackRegistry(address(attackRegistry));
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
}
function testInitializeAcceptsAccountWhoseBindingAgreementDiffersFromSelectedAgreement() external {
attackRegistry.setAgreementForContract(INITIAL_ACCOUNT, address(bindingAgreement));
attackRegistry.setAgreementState(address(selectedAgreement), IAttackRegistry.ContractState.ATTACK_REQUESTED);
attackRegistry.setAgreementState(address(bindingAgreement), IAttackRegistry.ContractState.CORRUPTED);
pool.initialize(
address(selectedAgreement),
address(token),
address(registry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
owner,
_single(INITIAL_ACCOUNT)
);
assertEq(
attackRegistry.getAgreementForContract(INITIAL_ACCOUNT),
address(bindingAgreement),
"test precondition: Binding Agreement differs"
);
assertEq(pool.agreement(), address(selectedAgreement), "pool stores selected agreement");
assertTrue(pool.isAccountInScope(INITIAL_ACCOUNT), "initializer accepted selected-agreement scope");
token.mint(staker, ONE);
vm.startPrank(staker);
token.approve(address(pool), ONE);
pool.stake(ONE);
vm.stopPrank();
assertEq(token.balanceOf(address(pool)), ONE, "stake followed selected state instead of binding state");
}
function testSetPoolScopeAcceptsReplacementAccountWhoseBindingAgreementDiffers() external {
attackRegistry.setAgreementState(address(selectedAgreement), IAttackRegistry.ContractState.NOT_DEPLOYED);
attackRegistry.setAgreementState(address(bindingAgreement), IAttackRegistry.ContractState.CORRUPTED);
attackRegistry.setAgreementForContract(REPLACEMENT_ACCOUNT, address(bindingAgreement));
pool.initialize(
address(selectedAgreement),
address(token),
address(registry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
owner,
_single(INITIAL_ACCOUNT)
);
vm.prank(owner);
pool.setPoolScope(_single(REPLACEMENT_ACCOUNT));
assertEq(
attackRegistry.getAgreementForContract(REPLACEMENT_ACCOUNT),
address(bindingAgreement),
"test precondition: replacement Binding Agreement differs"
);
assertFalse(pool.isAccountInScope(INITIAL_ACCOUNT), "old scope cleared");
assertTrue(pool.isAccountInScope(REPLACEMENT_ACCOUNT), "setPoolScope accepted non-binding account");
}
function _single(address account) internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = account;
}
}

Run:

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

Expected output summary:

Ran 2 tests for test/poc/BindingAgreementScopePoC.t.sol
[PASS] testInitializeAcceptsAccountWhoseBindingAgreementDiffersFromSelectedAgreement()
[PASS] testSetPoolScopeAcceptsReplacementAccountWhoseBindingAgreementDiffers()
2 tests passed, 0 failed, 0 skipped

Recommended Mitigation

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);
}
+ 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!