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

Scope Array Has No Max Length — Gas DOS Risk

Author Revealed upon completion

Root + Impact

Description

The _replaceScope() function iterates over the accounts array and makes an external call to IAgreement(agreement).isContractInScope(account) for every element. The only validation on the array is that it is non-empty. There is no upper bound on its length. A sufficiently large scope array consumes enough gas to approach or exceed the block gas limit, causing the transaction to revert. If this occurs during initialize(), the clone contract is already deployed by the factory before initialize is called, so the gas spent on deployment is wasted. If it occurs during setPoolScope(), the function becomes permanently uncallable with any scope — the owner is locked out of scope updates even with a small valid scope, because the function is still callable but the scope-lock gate (scopeLocked) is independent of the gas issue.

// ConfidencePool.sol — _replaceScope() lines 749-773
function _replaceScope(address[] calldata accounts) internal {
@> if (accounts.length == 0) revert EmptyScope(); // <-- only guard: non-empty
// Clear existing scope
address[] memory old = _scopeAccounts;
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
// Apply new scope — external call per element, no length cap
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
@> if (!IAgreement(agreement).isContractInScope(account)) { // <-- EXT call per iteration
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}

Risk

Likelihood: Low

  • Requires the pool owner to pass a maliciously large accounts array — the owner is a trusted role

  • The owner pays their own gas, so self-griefing is irrational

  • The primary concern is accidental: a sponsor with hundreds of BattleChain accounts in their agreement could legitimately exceed practical gas limits when initializing the pool

Impact: Low

  • During initialize(): the clone deployment gas is wasted because the factory deploys the clone (line 87 of ConfidencePoolFactory.sol) before calling initialize

  • During setPoolScope(): the function reverts after consuming gas, but scope changes are retryable with a smaller array (as long as scopeLocked is false)

  • No funds are at risk — this is a gas-wasting / liveness concern

Proof of Concept

File: L14-ScopeNoMaxLength.poc.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC for L-14: Scope array has no max length — gas DOS risk
contract L14_ScopeNoMaxLength_POC is BaseConfidencePoolTest {
function testPOC_L14_scopeNoMaxLength_gasScalesLinearly() external {
ConfidencePool freshPool = _deployPool();
address[] memory smallScope = new address[](5);
for (uint256 i; i < 5; ++i) {
address addr = makeAddr(string(abi.encodePacked("account", vm.toString(i))));
agreementContract.setContractInScope(addr, true);
smallScope[i] = addr;
}
uint256 gasBefore = gasleft();
freshPool.setPoolScope(smallScope);
uint256 gas5 = gasBefore - gasleft();
ConfidencePool freshPool2 = _deployPool();
address[] memory largeScope = new address[](50);
for (uint256 i; i < 50; ++i) {
address addr = makeAddr(string(abi.encodePacked("acct", vm.toString(i))));
agreementContract.setContractInScope(addr, true);
largeScope[i] = addr;
}
gasBefore = gasleft();
freshPool2.setPoolScope(largeScope);
uint256 gas50 = gasBefore - gasleft();
// Gas scales linearly with scope size — 50 accounts cost >8x gas of 5 accounts
assertGt(gas50, gas5 * 8, "gas scales with scope size; no cap exists");
}
function testPOC_L14_initializeWithHugeScopeReverts() external {
ConfidencePool implementation = new ConfidencePool();
ConfidencePool freshPool = ConfidencePool(Clones.clone(address(implementation)));
uint256 largeCount = 200;
address[] memory hugeScope = new address[](largeCount);
for (uint256 i; i < largeCount; ++i) {
hugeScope[i] = makeAddr(string(abi.encodePacked("huge", vm.toString(i))));
}
// Revert deep in the loop after consuming all prior gas
vm.expectRevert();
freshPool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
hugeScope
);
// Clone was deployed before initialize — that gas is wasted
}
}

Run with:

forge test --match-path 'L14-ScopeNoMaxLength.poc.t.sol' -vv

Recommended Mitigation

Add a MAX_SCOPE_SIZE constant (e.g. 100 accounts) and validate the array length against it in _replaceScope(). This bounds the per-iteration gas cost to a predictable ceiling and prevents both accidental and malicious gas exhaustion. The value of 100 is chosen to comfortably fit within any EVM-compatible L2 block gas limit even with the external isContractInScope call per account, while exceeding any realistic BattleChain agreement scope size. The check is also added to ConfidencePoolFactory.createPool() so that invalid scope sizes are caught before the clone is deployed, saving the caller from wasted deployment gas.

// In ConfidencePool.sol:
+ uint256 private constant MAX_SCOPE_SIZE = 100;
+ error ScopeTooLarge();
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
+ if (accounts.length > MAX_SCOPE_SIZE) revert ScopeTooLarge();
// ...
}

Also add the check in ConfidencePoolFactory.createPool() before deploying the clone:

// In ConfidencePoolFactory.sol — createPool():
+ 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!