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);
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();
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.
Sponsor creates a pool using agreement A.
The pool accepts X in scope because A.isContractInScope(X) == true.
The pool never checks AttackRegistry.getAgreementForContract(X) == A.
Agreement B, the real binding agreement for X, becomes CORRUPTED.
Agreement A becomes PRODUCTION.
The pool resolves as SURVIVED because it reads getAgreementState(A).
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
);
assertEq(bindingRegistry.getAgreementForContract(accountX), address(agreementB));
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);
}