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

Safe Harbor Child-Contract Scope Cannot Be Represented in Pool Scope

Author Revealed upon completion

Root + Impact

Description

Safe Harbor agreements can include child-contract coverage through ChildContractScope. A child contract can be legally covered by a parent account's Binding Agreement even when the child itself is not a top-level BattleChain account.

Confidence Pool scope is only a flat address[] of accounts. _replaceScope() validates each address with IAgreement(agreement).isContractInScope(account), and the Safe Harbor implementation's isContractInScope() only checks the top-level BattleChain scope cache. As a result, a child contract covered only through a parent cannot be added directly to a pool scope. A pool can include the parent, but pool.isAccountInScope(child) remains false and the pool has no field to represent ChildContractScope.All, ExistingOnly, FutureOnly, or the cutoff time.

The underlying issue is that the pool's on-chain scope functionality cannot faithfully represent a valid Safe Harbor child-coverage commitment.

The upstream scope model includes child coverage in AgreementTypes.sol#L41-L58:

struct Account {
string accountAddress;
ChildContractScope childContractScope;
}
enum ChildContractScope {
None,
ExistingOnly,
All,
FutureOnly
}

The Safe Harbor binding rules describe child-contract resolution in seal-agreement-modified.md#L345-L350:

Binding Agreement ... If AttackRegistry.getAgreementForContract(C) returns the zero address,
let P be the immediate deployer of C. If AttackRegistry.getAgreementForContract(P) returns a
non-zero address A, and the ChildContractScope set in A for accountAddress P permits inclusion
of C, then A is the Binding Agreement for C, and C is treated as a child contract of P under A.

The pool scope replacement path validates only the flat account list in ConfidencePool.sol::_replaceScope:

function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
// ... code clears the previous flat account list ...
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
// @> Only top-level account membership is accepted.
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
// @> No child-contract scope metadata can be stored for the account.
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
}

The current agreement scope predicate checks only the top-level cache in Agreement.sol::isContractInScope:

function isContractInScope(address contractAddress) external view returns (bool) {
return s_battleChainScopeExists[contractAddress];
}

The pool accepts only the top-level cache address. It cannot encode that a non-top-level child is legally covered by the parent account's child-contract setting.

Risk

Likelihood: Low

  • A Safe Harbor agreement uses parent-account child coverage such as All, ExistingOnly, or FutureOnly.

  • A sponsor wants a Confidence Pool to cover a specific child contract or wants the pool's scope API to expose that child coverage.

  • Integrations or users inspect getScopeAccounts() or isAccountInScope(child) to understand the pool's advertised coverage.

Impact: Low

  • A legally covered child contract cannot be added directly to the Confidence Pool scope.

  • A pool scoped to the parent does not expose child membership through isAccountInScope(child).

  • The on-chain pool scope cannot represent the child coverage mode or cutoff semantics that define the Safe Harbor Binding Agreement for child contracts.

Proof of Concept

The PoC file included with this report is:

ChildContractScopeNotRepresentablePoC.t.sol

To run it in a fresh contest checkout:

  1. Copy ChildContractScopeNotRepresentablePoC.t.sol into the repository's test/ directory.

  2. Run:

forge test --match-contract ChildContractScopeNotRepresentablePoC -vv

Expected result:

2 passed, 0 failed

The PoC demonstrates:

  • A mock agreement has parent P in top-level scope with ChildContractScope.All.

  • Child C is legally included by the Safe Harbor-style child resolver.

  • agreement.isContractInScope(C) returns false because C is not in the top-level BattleChain cache.

  • Initializing a pool directly scoped to C reverts with AccountNotInAgreementScope.

  • Initializing a pool scoped to P succeeds, but pool.isAccountInScope(C) remains false.

Inline PoC source (ChildContractScopeNotRepresentablePoC.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 {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {ChildContractScope} from "@battlechain/types/AgreementTypes.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract ChildAwareAgreementMock {
address internal immutable _owner;
mapping(address account => bool inScope) internal _topLevelScope;
mapping(address parent => ChildContractScope childScope) internal _childScope;
mapping(address parent => uint256 cutoffTime) internal _cutoffTime;
mapping(address child => address parent) internal _childParent;
mapping(address child => uint256 deployedAt) internal _childDeployedAt;
constructor(address owner_) {
_owner = owner_;
}
function owner() external view returns (address) {
return _owner;
}
function setTopLevelScope(address account, ChildContractScope scope) external {
_topLevelScope[account] = true;
_childScope[account] = scope;
_cutoffTime[account] = block.timestamp;
}
function setChild(address child, address parent, uint256 deployedAt) external {
_childParent[child] = parent;
_childDeployedAt[child] = deployedAt;
}
function isContractInScope(address contractAddress) external view returns (bool) {
return _topLevelScope[contractAddress];
}
function isChildLegallyInScope(address child) external view returns (bool) {
address parent = _childParent[child];
if (!_topLevelScope[parent]) return false;
ChildContractScope scope = _childScope[parent];
if (scope == ChildContractScope.None) return false;
if (scope == ChildContractScope.All) return true;
if (scope == ChildContractScope.ExistingOnly) {
return _childDeployedAt[child] <= _cutoffTime[parent];
}
return _childDeployedAt[child] > _cutoffTime[parent];
}
}
contract ChildContractScopeNotRepresentablePoC is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant PARENT = address(0xAA11);
address internal constant CHILD = address(0xCC11);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
ChildAwareAgreementMock internal agreement;
ConfidencePool internal pool;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal sponsor = makeAddr("sponsor");
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new ChildAwareAgreementMock(address(this));
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
agreement.setTopLevelScope(PARENT, ChildContractScope.All);
agreement.setChild(CHILD, PARENT, block.timestamp + 1);
}
function testCannotCreatePoolDirectlyForLegalChildContract() external {
assertTrue(agreement.isChildLegallyInScope(CHILD), "Safe Harbor resolver includes child");
assertFalse(agreement.isContractInScope(CHILD), "Agreement cache excludes child");
address[] memory childScope = new address[](1);
childScope[0] = CHILD;
ConfidencePool implementation = new ConfidencePool();
ConfidencePool deployedPool = ConfidencePool(Clones.clone(address(implementation)));
vm.expectRevert(abi.encodeWithSelector(IConfidencePool.AccountNotInAgreementScope.selector, CHILD));
deployedPool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
childScope
);
}
function testParentScopeDoesNotExposeChildButChildOnlyCorruptionCanSweepWhenModeratorAbsent() external {
pool = _newPool(_oneAddressScope(PARENT));
address[] memory publishedScope = pool.getScopeAccounts();
assertEq(publishedScope.length, 1, "flat scope has one account");
assertEq(publishedScope[0], PARENT, "flat scope publishes parent only");
assertTrue(pool.isAccountInScope(PARENT), "parent marked in pool");
assertFalse(pool.isAccountInScope(CHILD), "child not marked in pool");
assertTrue(agreement.isChildLegallyInScope(CHILD), "child is legally covered by parent");
_stake(alice, 100 * ONE);
_contributeBonus(sponsor, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "stake plus bonus swept");
}
function _newPool(address[] memory accounts) internal returns (ConfidencePool deployedPool) {
ConfidencePool implementation = new ConfidencePool();
deployedPool = ConfidencePool(Clones.clone(address(implementation)));
deployedPool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
accounts
);
}
function _oneAddressScope(address account) internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = account;
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
}

Recommended Mitigation

If Confidence Pools intend to support Safe Harbor child coverage, extend pool scope to represent child-contract semantics explicitly, or add a binding-aware resolver query for child contracts.

One direction is to store richer scope entries instead of a flat account list:

- address[] private _scopeAccounts;
- mapping(address => bool) public isAccountInScope;
+ struct PoolScopeAccount {
+ address account;
+ ChildContractScope childScope;
+ uint256 cutoffTime;
+ }
+ PoolScopeAccount[] private _scopeAccounts;
+ mapping(address => bool) public isAccountInScope;
+ mapping(address => bool) public isChildContractInScope;

If child coverage is intentionally unsupported, the factory and pool documentation should state that pool scope only covers top-level BattleChain accounts and that isAccountInScope(child) is not a Safe Harbor child-coverage resolver.

Support

FAQs

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

Give us feedback!