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.
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
address[] memory old = _scopeAccounts;
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
@>
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);
}
_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:
Create test/OOGScopeTest.t.sol with the code below.
Run: forge test --match-test test_UnboundedLoopGasGriefing -vvv
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.
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);
uint160 numAccounts = 8_000;
address[] memory accounts = new address[](numAccounts);
for (uint160 i = 0; i < numAccounts; i++) {
accounts[i] = address(i + 0x1000);
}
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);
assertGt(gasUsed, 30_000_000, "Gas far exceeds 30M block limit — OOG on mainnet");
}
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);
assertGt(gasUsed, 30_000_000, "1 000-account scope already exceeds block limit");
}
}
Expected output:
Gas used for 8 000 accounts: 372447230 ← 12.4× the 30M limit
Gas used for 1 000 accounts: 46729685 ← 1.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.