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

Unbounded `_replaceScope` loop causes permanent OOG DoS for scopes exceeding ~640 accounts — blocking pool creation and scope updates for mid-to-large protocols

Author Revealed upon completion

Root + Impact

Description

Under normal behavior, _replaceScope() performs a wholesale replacement of the pool's BattleChain scope: it first clears all existing entries (loop 1), then validates and adds every new entry (loop 2), making one external isContractInScope() call and two storage writes per account. Both loops are unbounded by any length cap.

The specific problem is that at approximately 640 accounts, the combined gas cost of the two loops crosses the 30M EVM block gas limit. Any createPool() or setPoolScope() call with a scope array larger than this threshold reverts on mainnet with Out-Of-Gas, permanently blocking pool creation for the sponsor.

// src/ConfidencePool.sol — _replaceScope (actual function, calldata not memory)
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
// LOOP 1 — also unbounded: clears the old scope on setPoolScope calls.
// For a replacement from N→M accounts, both loops run: N clears + M sets.
address[] memory old = _scopeAccounts;
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
@> // ROOT CAUSE: Unbounded loop — ~46 500 gas per account (SSTORE ×2 + external call)
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); // ← actual error name
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}

Measured gas per scope size (Foundry, confirmed with actual bytecode):

Accounts Gas used vs 30M block limit
500 23,537,388 0.8× (safe)
640 (approx threshold) ~30,000,000 1.0× (OOG on mainnet)
1,000 46,729,685 1.6× (fails)
3,000 139,596,529 4.7× (fails)
8,000 372,447,230 12.4× (fails)

Per-account gas cost is stable at ~46,500 gas (two SSTOREs + one external call), giving a consistent OOG threshold of approximately 640 accounts regardless of network conditions.


Risk

Likelihood:

  • DeFi protocols covering dozens of vault contracts, lending markets, or AMM pools can easily exceed 640 scope entries. This is not limited to "massive enterprise" protocols — any protocol with more than ~640 monitored contracts is blocked.

  • The setPoolScope path has an additional hazard: replacing a scope of N entries with M entries runs both loops, consuming gas proportional to N + M. A sponsor replacing a 400-entry scope with a 300-entry scope (400 + 300 = 700) already exceeds the threshold.

  • Once scopeLocked is set (on first registry observation past NEW_DEPLOYMENT), the scope can never be corrected. A sponsor who discovers OOG only after the registry transitions has permanently lost the ability to fix their pool.

Impact:

  • Sponsors of mid-to-large protocols cannot deploy a Confidence Pool with full scope coverage. The product is unusable for any agreement with more than ~640 covered contracts.

  • Partial workarounds (creating multiple pools for the same agreement, each with a smaller scope) exist but are undocumented and fragmented, splitting staker liquidity across pools.


Proof of Concept

Steps to Reproduce:

  1. Create test/OOGScopeTest.t.sol with the code below.

  2. Run: forge test --match-test test_UnboundedLoopGasGriefing -vvv

  3. Observe the console log: 372,447,230 gas for 8,000 accounts — 12.4× the 30M block gas limit. The assertGt confirms the threshold is violated by a factor of 12.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockToken is ERC20 { constructor() ERC20("Mock","MCK") {} }
contract MockRegistry {
function getAttackRegistry() external view returns (address) { return address(this); }
function isAgreementValid(address) external pure returns (bool) { return true; }
}
contract MockAgreement {
address public owner;
constructor(address _owner) { owner = _owner; }
function isContractInScope(address) external pure returns (bool) { return true; }
}
contract OOGScopeTest is Test {
ConfidencePoolFactory factory;
MockToken token;
address sponsor = address(0x111);
function setUp() public {
token = new MockToken();
MockRegistry registry = new MockRegistry();
ConfidencePool impl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeWithSelector(
ConfidencePoolFactory.initialize.selector,
address(registry), address(impl), address(this)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function test_UnboundedLoopGasGriefing() public {
vm.prank(sponsor);
MockAgreement agreement = new MockAgreement(sponsor);
// 8 000 accounts — representative of a mid-large DeFi protocol with
// many vaults, markets, or pair contracts in its BattleChain agreement.
uint160 numAccounts = 8_000;
address[] memory accounts = new address[](numAccounts);
for (uint160 i = 0; i < numAccounts; i++) {
accounts[i] = address(i + 0x1000); // unique non-zero addresses
}
uint256 gasBefore = gasleft();
vm.prank(sponsor);
factory.createPool(
address(agreement),
address(token),
block.timestamp + 60 days,
1 ether,
sponsor,
accounts
);
uint256 gasUsed = gasBefore - gasleft();
console.log("Gas used for 8 000 accounts:", gasUsed);
// Measured: 372 447 230 gas = 12.4x the 30M EVM block gas limit.
// OOG threshold is ~640 accounts (~46 500 gas each).
assertGt(gasUsed, 30_000_000, "Gas far exceeds 30M block limit — OOG on mainnet");
}
// Additional check: shows OOG begins at ~640 accounts, not just at enterprise scale
function test_OOGThresholdAt1000Accounts() public {
vm.prank(sponsor);
MockAgreement agreement = new MockAgreement(sponsor);
address[] memory accounts = new address[](1_000);
for (uint160 i = 0; i < 1_000; i++) accounts[i] = address(i + 0x2000);
uint256 gasBefore = gasleft();
vm.prank(sponsor);
factory.createPool(
address(agreement), address(token),
block.timestamp + 60 days, 1 ether, sponsor, accounts
);
uint256 gasUsed = gasBefore - gasleft();
console.log("Gas used for 1 000 accounts:", gasUsed);
// Measured: 46 729 685 gas = 1.6x the block limit.
// Even a modest protocol with 1 000 contracts already fails on mainnet.
assertGt(gasUsed, 30_000_000, "1 000-account scope already exceeds block limit");
}
}

Expected output:

Gas used for 8 000 accounts: 37244723012.4× the 30M limit
Gas used for 1 000 accounts: 467296851.6× the 30M limit

Recommended Mitigation

Add a hard cap on the scope array length. Based on measured gas, 500 accounts fits comfortably within the 30M block limit (23.5M gas), leaving margin for the rest of the transaction:

+ // In IConfidencePool.sol — add new custom error:
+ error ScopeTooLarge();
// In ConfidencePool.sol — _replaceScope:
- function _replaceScope(address[] calldata accounts) internal {
+ function _replaceScope(address[] calldata accounts) internal {
+ if (accounts.length > MAX_SCOPE_SIZE) revert ScopeTooLarge();
if (accounts.length == 0) revert EmptyScope();
...
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
- revert AccountNotInScope(account);
+ revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
+ // In ConfidencePool.sol — state variable:
+ uint256 public constant MAX_SCOPE_SIZE = 500;

For protocols that genuinely require more than 500 accounts, an alternative is to introduce an appendScope(address[] calldata additions) function that adds to the existing scope incrementally before scopeLocked is set, with each call bounded to MAX_SCOPE_SIZE additions.

Support

FAQs

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

Give us feedback!