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

Factory defers expiry/minStake bounds validation to the clone initializer, wasting gas and degrading error surfacing

Author Revealed upon completion

Root + Impact

Description

  • ConfidencePoolFactory.createPool is the sole entrypoint for creating pools; it validates the forwarded parameters and then deploys a deterministic clone whose initialize performs a second, stricter round of parameter validation.

  • The factory only partially mirrors the clone's parameter checks: it enforces the expiry lower bound but omits the expiry <= type(uint32).max upper bound and the minStake != 0 check. Because the clone is deployed via Clones.cloneDeterministic before initialize runs, an out-of-range expiry or a zero minStake is only rejected after the CREATE2 deployment has executed, wasting that gas and surfacing a generic error from a different contract than the one the caller invoked.

// ConfidencePoolFactory.sol - createPool
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
@> // missing: if (expiry > type(uint32).max) revert ExpiryTooFar();
@> // missing: if (minStake == 0) revert InvalidAmount();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
bytes32 salt = keccak256(abi.encode(agreement, _poolsByAgreement[agreement].length));
@> pool = Clones.cloneDeterministic(poolImplementation, salt); // deploy happens first
@> IConfidencePool(pool).initialize(agreement, stakeToken, ..., expiry, minStake, ...); // reverts only here
// ConfidencePool.sol - initialize (where the omitted bounds actually live)
@> if (expiry_ > type(uint32).max) revert ExpiryTooFar();
@> if (minStake_ == 0) revert InvalidAmount();

Risk

Likelihood:

  • Low. The offending inputs are caller-controlled and self-directed — only the agreement owner can call createPool (IAgreement(agreement).owner() != msg.sender gate), so this only fires on a sponsor mis-parameterizing their own pool creation, typically a fat-finger minStake = 0 or a UI passing an unbounded/huge expiry.

  • Occurs on any createPool call carrying minStake == 0 or expiry > type(uint32).max; the transaction always reverts atomically, so it never produces a corrupt or half-deployed pool — the consequence is confined to the failed attempt.

Impact:

  • Wasted gas: the full CREATE2 clone deployment runs and is then thrown away when initialize reverts within the same transaction.

  • Degraded error surfacing: the caller receives a generic InvalidAmount / ExpiryTooFar originating from the clone rather than a clear factory-level rejection, making the failure harder to diagnose for integrators/scripts.

  • No fund, security, state, or liveness impact - no clone persists, _poolsByAgreement is untouched, and the (agreement, index) salt is unconsumed and reusable on a corrected retry.

Proof of Concept

  1. Add the import near the other imports at the top of test/unit/ConfidencePoolFactory.t.sol:

  2. Paste the two test functions inside the existing ConfidencePoolFactoryTest contract (its setUp, _scope(), agreementOwner, token, agreement, recovery, and ONE are already defined and cover everything these need):

3. Run from the repo root with traces enabled:

cd "/Users/sam/Desktop/bug bounty/2026-07-bc-confidence-pools"
forge test --match-test "testRevert\_createPool\_(zeroMinStake|expiryTooFar)\_revertsFromClone" -vvv

4. Read the trace, not the pass/fail. Both tests pass — the point is what the -vvv trace shows: Clones::cloneDeterministic deploying the clone completes first, and the revert frame (InvalidAmount / ExpiryTooFar) appears underneath it, emitted by the newly-deployed pool rather than by the factory you called. That ordering, deployment gas spent, then a downstream pool-level revert, is the finding.

import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
function testRevert_createPool_zeroMinStake_revertsFromClone() external {
// Passes every factory-level check (expiry lower-bound, token allowed, owner).
// The clone is deployed via CREATE2, then initialize() is the FIRST place
// minStake == 0 is rejected — note the selector belongs to IConfidencePool,
// not IConfidencePoolFactory, proving validation happened downstream.
vm.prank(agreementOwner);
vm.expectRevert(IConfidencePool.InvalidAmount.selector);
factory.createPool(address(agreement), address(token), block.timestamp + 31 days, 0, recovery, _scope());
}
function testRevert_createPool_expiryTooFar_revertsFromClone() external {
// Factory only enforces the expiry LOWER bound; this sails through, the clone
// is deployed, and initialize() reverts with ExpiryTooFar (a pool-level error).
vm.prank(agreementOwner);
vm.expectRevert(IConfidencePool.ExpiryTooFar.selector);
factory.createPool(
address(agreement), address(token), uint256(type(uint32).max) + 1, ONE, recovery, _scope()
);
}

Recommended Mitigation

Mirror the two missing bounds in createPool before the clone is deployed, so invalid input is rejected cheaply with a clear factory-level error. Keep the initializer checks as defense-in-depth (the pool must stay safe when initialized through any path). This requires exposing ExpiryTooFar / InvalidAmount (or equivalent named errors) on IConfidencePoolFactory.

if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
+ if (expiry > type(uint32).max) revert ExpiryTooFar();
+ if (minStake == 0) revert InvalidAmount();
// aderyn-fp-next-line(reentrancy-state-change)
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();

Support

FAQs

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

Give us feedback!