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

Factory allows creating pools for active-risk agreements, bypassing intended staging invariant

Author Revealed upon completion

Description

  • The ConfidencePool design fundamentally assumes pools are created during a pre-attack staging phase (NOT_DEPLOYED or NEW_DEPLOYMENT). This allows stakers a period to safely deposit funds, review the scope, and freely withdraw before any risk materializes.

  • The ConfidencePoolFactory.createPool() function validates registration (isAgreementValid(agreement)) but fails to verify the agreement's live state. This allows a Sponsor to deploy a pool for an agreement that is already in an active-risk state (UNDER_ATTACK or ATTACK_REQUESTED).

  • While the contract's documentation explicitly accepts that late deposits into an already UNDER_ATTACK pool will "self-lock" (this is intended behavior for existing pools), deploying a fresh pool in this state breaks the overarching assumption that newly created pools begin in a safe staging phase. It allows a bad-faith Sponsor to create a deceptive pool listing that has no legitimate staging window and no true economic purpose.

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
// @> UNDER_ATTACK and ATTACK_REQUESTED are missing from this blocklist, meaning deposits succeed.
// @> While intended for existing pools, creating a fresh pool in this state bypasses the staging phase.
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

Risk

Likelihood: Medium

  • An agreement can enter ATTACK_REQUESTED entirely via a self-service action by the Sponsor (no DAO approval needed), meaning they can unilaterally trigger this condition right before deploying the pool.

  • To trigger the UNDER_ATTACK condition, the Sponsor simply deploys the pool immediately after a DAO approveAttack transaction lands on-chain.

Impact: Medium

  • Deceptive Pool Listings & Assumption Mismatch: Any off-chain tooling, listing UI, or staker heuristic that treats a "newly created pool" as shorthand for "still in safe staging phase" is silently violated.

  • Stakers depositing into this pool are immediately subject to the documented UNDER_ATTACK self-lock. Their withdraw() is permanently disabled on their first interaction.

  • Perverse Bonus Weighting: Because the trapped staker's deposit is the first interaction that observes the active risk state, it sets riskWindowStart to the exact block timestamp of their entry. Since entryTime == riskWindowStart, the k=2 bonus formula treats them as if they had been carrying risk from the very beginning of the window, granting them the maximum possible bonus weighting.

  • While not a novel fund-safety bug (stakers could have checked the public registry state), it breaks a core product-integrity assumption and creates a deceptive surface where the pool's economic purpose (accepting capital before risk exists) never applies.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract FactoryTerminalStatePoCTest is BaseConfidencePoolTest {
function testCreatePoolForUnderAttackAgreementTrapsStakers() external {
// 1. Deploy the factory
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(poolImplementation), moderator))
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
// 2. The registry agreement is already UNDER_ATTACK
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// 3. The Sponsor calls createPool. It succeeds.
vm.prank(agreementContract.owner());
address newPoolAddress = factory.createPool(
agreement,
address(token),
block.timestamp + 31 days,
1e18,
recovery,
_defaultScope()
);
IConfidencePool newPool = IConfidencePool(newPoolAddress);
// 4. A staker deposits, assuming a newly created pool is in the staging phase.
token.mint(alice, 100e18);
vm.startPrank(alice);
token.approve(address(newPool), 100e18);
// Staking SUCCEEDS!
newPool.stake(100e18);
// They are instantly subject to the intended UNDER_ATTACK self-lock.
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
newPool.withdraw();
vm.stopPrank();
}
}

Recommended Mitigation

Add an explicit check to the Factory to ensure the agreement is in a valid staging state before deploying the pool. This enforces the staging-phase invariant the pool design assumes at creation time, preventing deceptive/no-op pool listings, while also preventing gas-waste for terminal states.

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();
// aderyn-fp-next-line(reentrancy-state-change)
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
// aderyn-fp-next-line(reentrancy-state-change)
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
+ address attackReg = safeHarborRegistry.getAttackRegistry();
+ IAttackRegistry.ContractState state = IAttackRegistry(attackReg).getAgreementState(agreement);
+ if (state != IAttackRegistry.ContractState.NOT_DEPLOYED && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT) {
+ revert InvalidAgreementState();
+ }
// Salt incorporates the per-agreement index so an agreement can back many pools while
// keeping deterministic, collision-free clone addresses.

Support

FAQs

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

Give us feedback!