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

Unbounded pool enumeration can exceed practical call gas

Author Revealed upon completion

Root + Impact

Description

getPoolsByAgreement() copies an agreement's complete append-only pool array into memory and return data. Because pool creation has no per-agreement bound and no paginated or indexed getter exists, sufficiently large histories cannot be enumerated within a caller's gas budget.

The factory intentionally permits an agreement to create multiple pools and exposes poolCountByAgreement() for the current length. Consumers should also be able to discover the corresponding addresses without making a single call whose cost grows forever.

Instead, every successful createPool() appends another storage slot and the only address getter returns the entire array. The EVM must cold-read every element, allocate an equally large memory array, and ABI-encode all addresses before returning even the first entry. The caller cannot request a range or one index.

src/ConfidencePoolFactory.sol
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
// ... validation, clone deployment, and initialization omitted ...
_poolsByAgreement[agreement].push(pool); // @> The per-agreement array can grow without a bound.
emit PoolCreated(
agreement,
pool,
stakeToken,
expiry,
minStake,
recoveryAddress,
defaultOutcomeModerator,
address(safeHarborRegistry)
);
}
function getPoolsByAgreement(address agreement) external view returns (address[] memory) {
return _poolsByAgreement[agreement]; // @> Every call reads, copies, and returns the complete history.
}
function poolCountByAgreement(address agreement) external view returns (uint256) {
return _poolsByAgreement[agreement].length;
}

The PoC seeds the exact storage state reachable through 3,000 successful createPool() calls. A cold call to the production getter fails when given six million gas, while the same state succeeds only when the budget is raised to ten million. The threshold grows linearly with every later pool and will eventually exceed any fixed RPC, transaction, or subcall budget.

Risk

Likelihood:

  • The issue develops as one agreement legitimately creates a large number of pools over time; no unauthorized caller can grow another agreement's list because creation requires the agreement owner.

  • On-chain integrations routinely impose bounded subcall gas, and RPC providers may impose independent execution-gas or response-size caps.

Impact:

  • Bounded-gas consumers lose the ability to enumerate any pool through the advertised whole-array getter once the list crosses their limit.

  • Individual pools remain functional and off-chain consumers can reconstruct the list from PoolCreated logs, so no pool funds are directly at risk.

Proof of Concept

Create test/audit/CP012UnboundedPoolEnumeration.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract CP012UnboundedPoolEnumerationTest is Test {
uint256 internal constant POOLS_BY_AGREEMENT_SLOT = 4;
uint256 internal constant POOL_COUNT = 3_000;
uint256 internal constant BOUNDED_CALL_GAS = 6_000_000;
ConfidencePoolFactory internal factory;
address internal agreement = makeAddr("agreement");
function setUp() external {
MockSafeHarborRegistry registry = new MockSafeHarborRegistry();
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(registry), address(poolImplementation), makeAddr("moderator"))
)
)
)
);
}
function test_WholeArrayGetterExceedsBoundedCallGas() external {
_seedReachablePoolList(POOL_COUNT);
assertEq(factory.poolCountByAgreement(agreement), POOL_COUNT);
_coolPoolList(POOL_COUNT);
bytes memory callData = abi.encodeCall(ConfidencePoolFactory.getPoolsByAgreement, (agreement));
(bool boundedSuccess,) = address(factory).staticcall{gas: BOUNDED_CALL_GAS}(callData);
assertFalse(boundedSuccess, "whole-array enumeration exceeds the six-million-gas subcall budget");
_coolPoolList(POOL_COUNT);
(bool highGasSuccess, bytes memory result) = address(factory).staticcall{gas: 10_000_000}(callData);
assertTrue(highGasSuccess, "the same state remains enumerable only with a larger budget");
assertEq(abi.decode(result, (address[])).length, POOL_COUNT);
}
function _seedReachablePoolList(uint256 count) internal {
// `_poolsByAgreement` is at factory storage slot 4. Direct storage seeding avoids spending
// test time deploying 3,000 otherwise ordinary clones; repeated createPool calls reach the
// exact same append-only state.
bytes32 arraySlot = keccak256(abi.encode(agreement, POOLS_BY_AGREEMENT_SLOT));
bytes32 firstElementSlot = keccak256(abi.encode(arraySlot));
for (uint256 i; i < count; ++i) {
vm.store(
address(factory),
bytes32(uint256(firstElementSlot) + i),
bytes32(uint256(uint160(address(uint160(i + 1)))))
);
}
vm.store(address(factory), arraySlot, bytes32(count));
}
function _coolPoolList(uint256 count) internal {
bytes32 arraySlot = keccak256(abi.encode(agreement, POOLS_BY_AGREEMENT_SLOT));
bytes32 firstElementSlot = keccak256(abi.encode(arraySlot));
vm.coolSlot(address(factory), arraySlot);
for (uint256 i; i < count; ++i) {
vm.coolSlot(address(factory), bytes32(uint256(firstElementSlot) + i));
}
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP012UnboundedPoolEnumeration.t.sol -vv.

  2. Confirm that the six-million-gas static call fails, the ten-million-gas call succeeds, and the returned array contains all 3,000 entries.

Recommended Mitigation

Add bounded pagination and an indexed getter. Retain poolCountByAgreement() so consumers can discover the range, and deprecate the unbounded whole-array getter for on-chain use.

src/ConfidencePoolFactory.sol
-function getPoolsByAgreement(address agreement) external view returns (address[] memory) {
- return _poolsByAgreement[agreement];
+function getPoolsByAgreement(address agreement, uint256 offset, uint256 limit)
+ external
+ view
+ returns (address[] memory pools)
+{
+ uint256 total = _poolsByAgreement[agreement].length;
+ if (offset >= total || limit == 0) return new address[](0);
+ uint256 remaining = total - offset;
+ uint256 length = limit < remaining ? limit : remaining;
+ pools = new address[](length);
+ for (uint256 i; i < length; ++i) {
+ pools[i] = _poolsByAgreement[agreement][offset + i];
+ }
+}
+
+function poolByAgreementAt(address agreement, uint256 index) external view returns (address) {
+ return _poolsByAgreement[agreement][index];
}
function poolCountByAgreement(address agreement) external view returns (uint256) {
return _poolsByAgreement[agreement].length;
}

Mirror the two new signatures in IConfidencePoolFactory, and choose/document a maximum permitted limit when predictable upper-bound gas is required.

Support

FAQs

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

Give us feedback!