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

`ConfidencePoolFactory.createPool` missing `expiry <= type(uint32).max` upper bound check

Author Revealed upon completion

Description

Normal Behavior

The ConfidencePoolFactory is intended to completely validate input parameters before deploying and initializing a new clone of ConfidencePool. This ensures the factory cleanly rejects invalid configurations rather than spending gas deploying a clone that will immediately revert during its initialize call.

Specific Issue

The factory's createPool function checks the lower bound of expiry (if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();) but forgets to check the upper bound (expiry > type(uint32).max). Because initialize() in ConfidencePool does check this, the clone initialization reverts with ExpiryTooFar(). The transaction fails, but it fails ungracefully after deploying the proxy, rather than validating at the factory level.

// src/ConfidencePoolFactory.sol:createPool()
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
// @> Root cause: Missing upper bound check: if (expiry > type(uint32).max) revert ExpiryTooFar();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();

Risk

Likelihood:

  • The sponsor mistakenly enters an expiry timestamp exceeding the uint32.max threshold (Year 2106).

  • The frontend incorrectly formats the expiry as milliseconds instead of seconds, trivially exceeding the bound.

Impact:

  • The transaction reverts from the inner clone's initialize call rather than cleanly at the factory input validation stage.

  • Minor gas inefficiency on revert due to performing the Clones.cloneDeterministic deployment before catching the error.

Proof of Concept

Create a new test file test/unit/POCTest.t.sol inheriting from BaseConfidencePoolTest, paste the following code inside the contract body, and run it using the command forge test --match-test test_POC_FactoryExpiryValidation -vvv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract POCTest is BaseConfidencePoolTest {
function test_POC_FactoryExpiryValidation() external {
console.log("--- POC: Factory Expiry Validation ---");
// Setup factory correctly with initialization
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeWithSelector(
ConfidencePoolFactory.initialize.selector,
address(safeHarborRegistry),
address(new ConfidencePool()),
moderator
)
);
ConfidencePoolFactory testFactory = ConfidencePoolFactory(address(proxy));
testFactory.setStakeTokenAllowed(address(token), true);
address[] memory accs = new address[](1);
accs[0] = DEFAULT_SCOPE_ACCOUNT;
uint256 invalidExpiry = uint256(type(uint32).max) + 1;
console.log("Attempting to create pool with expiry > uint32.max :", invalidExpiry);
// The factory does NOT check the upper bound, but the clone's initialize does.
// So the revert will be ExpiryTooFar() from the IConfidencePool contract, not the factory.
vm.expectRevert(IConfidencePool.ExpiryTooFar.selector);
// We prank as agreement owner to bypass UnauthorizedCreator
vm.prank(address(this));
testFactory.createPool(
address(agreement),
address(token),
invalidExpiry,
1 * ONE,
recovery,
accs
);
console.log("Successfully caught ExpiryTooFar() bubbling up from the clone's initialize() rather than factory.");
}
}

Recommended Mitigation

To gracefully reject invalid expiry inputs at the factory level and save unnecessary proxy deployment gas, add a type(uint32).max upper bound check alongside the existing lower bound check.

function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
+ if (expiry > type(uint32).max) revert ExpiryTooFar();

Support

FAQs

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

Give us feedback!