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

Centralization risk — factory `pause()` is single-admin-key controlled, allowing instant censorship of new pool creation with no time-lock or multisig

Author Revealed upon completion

Root + Impact

Description

ConfidencePoolFactory inherits PausableUpgradeable, and the sole factory owner can call pause() at any time, blocking all calls to createPool() via the whenNotPaused modifier. No time-lock, multisig, or DAO vote is required.

// src/ConfidencePoolFactory.sol
function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
@> ) external whenNotPaused returns (address pool) { // blocked by pause()
...
}
// aderyn-ignore-next-line(centralization-risk) ← team already acknowledges this
function pause() external onlyOwner {
_pause();
}

Note: the // aderyn-ignore-next-line(centralization-risk) annotation on every admin function confirms the development team has already audited and accepted this centralization vector. Pausing the factory does not affect existing pool clones — stakers can always withdraw, existing pool logic is unaffected. The impact is limited to preventing new pool creation.


Risk

Likelihood:

  • A compromised owner key or malicious upgrade to the factory's access control could trigger pause() without community consent.

  • Because there is no time-lock, the pause takes effect in the same block as the owner's transaction with no warning window for sponsors.

Impact:

  • New pool creation is blocked for all sponsors for as long as the factory remains paused.

  • Existing pool stakers, bonus contributors, and outcome moderators are entirely unaffected — no funds are at risk in deployed pools.

  • Severity is bounded to degraded availability for new entrants only; no financial loss to existing participants.


Proof of Concept

Steps to Reproduce:

  1. Create test/PauseTest.t.sol with the code below.

  2. Run: forge test --match-test test_AdminPauseBlocksCreatePool -vvv

  3. The test passes, confirming the owner can unilaterally block all new pool creation.

Important: This codebase uses OpenZeppelin v5. The pause revert is a custom error EnforcedPause(), not the OZ v4 string "Pausable: paused". Using vm.expectRevert("Pausable: paused") will fail silently.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import "forge-std/Test.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") {}
function mint(address to, uint256 amt) external { _mint(to, amt); }
}
/// @dev Returns NEW_DEPLOYMENT (1) so the pool can be created without
/// immediately triggering a terminal state observation.
contract MockRegistry {
function getAttackRegistry() external view returns (address) { return address(this); }
function isAgreementValid(address) external pure returns (bool) { return true; }
function getAgreementState(address) external pure returns (uint8) { return 1; }
}
contract MockAgreement {
address public owner;
constructor(address _o) { owner = _o; }
function isContractInScope(address) external pure returns (bool) { return true; }
}
contract PauseTest is Test {
ConfidencePoolFactory factory;
MockToken token;
address sponsor = makeAddr("sponsor");
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) // address(this) = owner
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function test_AdminPauseBlocksCreatePool() public {
// 1. Admin (owner) pauses the factory — single key, no time-lock, no multisig
factory.pause();
// 2. Sponsor prepares a valid pool creation call
vm.prank(sponsor);
MockAgreement agreement = new MockAgreement(sponsor);
address[] memory scope = new address[](1);
scope[0] = address(0x999);
// 3. OZ v5 reverts with EnforcedPause() custom error (not "Pausable: paused" string)
vm.expectRevert(bytes4(keccak256("EnforcedPause()")));
vm.prank(sponsor);
factory.createPool(
address(agreement),
address(token),
block.timestamp + 60 days,
1 ether,
sponsor,
scope
);
}
}

Recommended Mitigation

Route pause() and unpause() through a time-locked multisig rather than a single EOA owner. This gives the community a response window before a pause takes effect:

- function pause() external onlyOwner {
+ // Caller must be a TimelockController (e.g. OZ TimelockController with
+ // minDelay >= 48 hours) to prevent instant censorship by a single key.
+ function pause() external onlyOwner {
_pause();
}

For reference, a 48-hour TimelockController between the DAO multisig and the factory owner would allow the community to observe and contest malicious pause calls before they take effect. Alternatively, remove the pause capability entirely from the factory if emergency halting is not a core protocol requirement — the aderyn-ignore-next-line(centralization-risk) annotations already indicate the team has weighed this trade-off.


Support

FAQs

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

Give us feedback!