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) {
_poolsByAgreement[agreement].push(pool);
emit PoolCreated(
agreement,
pool,
stakeToken,
expiry,
minStake,
recoveryAddress,
defaultOutcomeModerator,
address(safeHarborRegistry)
);
}
function getPoolsByAgreement(address agreement) external view returns (address[] memory) {
return _poolsByAgreement[agreement];
}
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:
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 {
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:
-
Execute forge test --offline --match-path test/audit/CP012UnboundedPoolEnumeration.t.sol -vv.
-
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.