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

Pool scope can be validated against a non-binding Safe Harbor agreement, causing settlement to track the wrong agreement state

Author Revealed upon completion

Root + Impact

Confidence Pools are expected to settle based on the BattleChain agreement that actually governs the scoped contracts. Stakers deposit capital because the pool commits to a list of BattleChain accounts, then the pool resolves using the Safe Harbor registry state for the agreement attached to that pool.

However, pool scope validation only checks whether each account is listed in the selected agreement via `IAgreement.isContractInScope(account)`. The upstream BattleChain interface explicitly warns that this does not prove the agreement is the Binding Agreement for that contract, because multiple agreements may include the same contract in their scope.

As a result, a pool can be created for agreement `A` while its scoped account is actually bound to agreement `B`. The pool will still resolve using `getAgreementState(A)`, not the scoped account’s binding agreement. This allows the pool outcome to diverge from the real state of the contracts that stakers believe are covered.

# Root Cause

In `ConfidencePool._replaceScope`, scope validation only checks the selected agreement’s local scope cache:

function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
address[] memory old = _scopeAccounts;
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
// @> Only checks that `account` is listed in `agreement`
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}

But the pool later resolves using the state of the pool’s selected agreement:

function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
// @> Resolves the pool's agreement, not the binding agreement of each scoped account
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

The missing check is against the canonical binding resolver:

IAttackRegistry(attackRegistry).getAgreementForContract(account)

Risk

Likelihood: Medium

  • A pool sponsor controls pool creation and initial scope.

  • The factory only requires that the sponsor owns the selected agreement.

  • The BattleChain interfaces state that multiple agreements may include the same contract in scope.

  • Therefore, isContractInScope(account) is insufficient to prove that the selected agreement governs the account.

Impact: Medium

  • Pool settlement can be based on the wrong agreement state.

  • Stakers can receive stake plus bonus even though the actually bound scoped contract was corrupted.

  • Conversely, stakers’ principal can be swept through a CORRUPTED resolution of a non-binding agreement even though the scoped contract’s real binding agreement survived.

  • The loss is bounded to the affected pool, so this is Medium rather than High.

Proof of Concept

Assume contract/account X is listed in agreement A, but BattleChain’s canonical binding registry says X is actually bound to agreement B.

  1. Sponsor creates a pool using agreement A.

  2. The pool accepts X in scope because A.isContractInScope(X) == true.

  3. The pool never checks AttackRegistry.getAgreementForContract(X) == A.

  4. Agreement B, the real binding agreement for X, becomes CORRUPTED.

  5. Agreement A becomes PRODUCTION.

  6. The pool resolves as SURVIVED because it reads getAgreementState(A).

  7. Stakers claim principal plus bonus even though the scoped contract failed under its real binding agreement.

function testPoolAcceptsNonBindingAgreementScope() external {
address accountX = address(0xBEEF);
MockAgreement agreementA = new MockAgreement(sponsor);
MockAgreement agreementB = new MockAgreement(sponsor);
agreementA.setContractInScope(accountX, true);
agreementB.setContractInScope(accountX, true);
registry.setAgreementValid(address(agreementA), true);
registry.setAgreementValid(address(agreementB), true);
bindingRegistry.setAgreementForContract(accountX, address(agreementB));
bindingRegistry.setAgreementState(address(agreementA), IAttackRegistry.ContractState.PRODUCTION);
bindingRegistry.setAgreementState(address(agreementB), IAttackRegistry.ContractState.CORRUPTED);
address[] memory scope = new address[](1);
scope[0] = accountX;
vm.prank(sponsor);
address pool = factory.createPool(
address(agreementA),
address(token),
block.timestamp + 31 days,
1e18,
recovery,
scope
);
// Pool was created even though accountX is not bound to agreementA.
assertEq(bindingRegistry.getAgreementForContract(accountX), address(agreementB));
// The pool resolves using agreementA's PRODUCTION state.
vm.prank(moderator);
ConfidencePool(pool).flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}

This demonstrates the core issue: the pool accepts a scoped account whose canonical binding agreement differs from the agreement used for settlement.

Recommended Mitigation

During scope validation, require every scoped BattleChain account to be bound to the pool agreement:


function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ if (attackRegistry == address(0)) revert InvalidAgreement();
address[] memory old = _scopeAccounts;
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
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);
}
+ if (IAttackRegistry(attackRegistry).getAgreementForContract(account) != agreement) {
+ revert AccountNotInAgreementScope(account);
+ }
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}

Support

FAQs

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

Give us feedback!