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

[L-02] Unbounded _scopeAccounts array - no cap allows gas exhaustion in scope replacement

Author Revealed upon completion

Root + Impact

Description

_replaceScope() performs a wholesale replacement of the scope array: it clears the old entries, then fills in the new ones. Both operations loop over the full array with no upper bound on size. The clearing phase alone iterates the entire existing _scopeAccounts array and writes a false to each isAccountInScope mapping slot:

// ConfidencePool.sol line 754-758
// @> Unbounded loop - no maximum length enforced
address[] memory old = _scopeAccounts;
for (uint256 i; i < old.length; ++i) {
// @> One SSTORE per account in the old scope
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;

There is no MAX_SCOPE_SIZE constant anywhere in the contract. A sponsor who initializes a pool with a very large scope, or who calls setPoolScope() with a large array, can cause both the clearing and the fill loops to exceed block gas limits.

Risk

Likelihood:

  • A sponsor must deliberately or accidentally supply a very large accounts array. Normal usage with modest scope sizes (tens of accounts) is unaffected.

  • The risk increases if the protocol's agreement contracts regularly include hundreds of BattleChain accounts in scope.

Impact:

  • If the clearing loop exceeds the block gas limit, setPoolScope() permanently reverts, trapping the pool in its current scope with no on-chain fix path (scope updates are permanently disabled).

  • If the fill loop exceeds the gas limit during initialize(), pool creation fails entirely, blocking the factory from deploying pools for large-scope agreements.

Proof of Concept

The issue is demonstrated in two stages. First, a sponsor creates a pool with a large scope. Second, because the clearing loop now has to process all those entries, a subsequent scope update costs even more gas and may permanently fail, trapping the pool in an uneditable state.

// Stage 1 — Pool initialized with 300 accounts (assume it succeeds, perhaps at a lower count).
// _scopeAccounts.length == 300, isAccountInScope filled for 300 addresses.
// Stage 2 — Sponsor calls setPoolScope() to update with a new 300-account list.
// _replaceScope() executes:
//
// Clearing loop (old scope):
// 300 × ~5 000 gas (warm SSTORE false) = 1 500 000 gas
// delete _scopeAccounts = ~5 000 gas
//
// Fill loop (new scope):
// 300 × ~20 000 gas (cold SSTORE true) = 6 000 000 gas
// 300 × external isContractInScope call = ~900 000 gas
//
// Total ≈ 8 405 000 gas
// → Exceeds typical L2 block gas limit → transaction reverts permanently.
//
// setPoolScope() will now always revert for this pool.
// The scope is frozen at its 300-account initialization list with no on-chain remedy.

Recommended Mitigation

+ uint256 private constant MAX_SCOPE_SIZE = 50; // tune to protocol needs
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
+ if (accounts.length > MAX_SCOPE_SIZE) revert ScopeTooLarge();
...
}

Support

FAQs

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

Give us feedback!