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

`createPool()` does not verify live registry state, allowing pool deployment for already-terminal agreements that permanently block staking

Author Revealed upon completion

Root + Impact

Description

Under normal behavior, a sponsor deploys a Confidence Pool to provide active financial coverage for a live, ongoing BattleChain agreement. The factory validates that the agreement is registered via safeHarborRegistry.isAgreementValid(agreement), but it never queries the live ContractState from the attack registry. A sponsor can therefore successfully create a pool for an agreement that is already PRODUCTION (survived) or CORRUPTED (breached).

// 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();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
@> if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
@> // ROOT CAUSE: only checks registration, never queries live ContractState.
@> // PRODUCTION (5) and CORRUPTED (6) agreements pass this check without issue.
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
...
}

The moment any user interacts with the deployed pool — via stake(), contributeBonus(), or pokeRiskWindow()_observePoolState() observes the terminal registry state, seals riskWindowEnd, and permanently blocks all staking. The pool becomes a dead shell on its very first interaction.


Risk

Likelihood:

  • A sponsor whose agreement recently reached PRODUCTION (survived the full term) may attempt to roll forward and create a new coverage pool without verifying the agreement can accept one, consuming gas on a guaranteed-useless deployment.

  • Front-end or automation scripts broadcasting createPool() can race against a DAO state transition, deploying a terminal-state pool in the same block the agreement resolves.

Impact:

  • The sponsor pays full deployment gas for a pool that can never be staked into, wasting funds and producing misleading on-chain state (a live pool address that is already dead).

  • Stakers or protocols integrating via the pool address may not detect the dead-on-arrival status until they try to stake and receive StakingClosed().

  • No third-party financial loss; the severity is bounded to the sponsor's wasted gas and degraded UX.


Proof of Concept

Steps to Reproduce:

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

  2. Run: forge test --match-path "test/TerminalPool.t.sol" -vvv

  3. Both tests pass — confirming the factory accepts PRODUCTION (state 5) and CORRUPTED (state 6) agreements without error, and that the resulting pool immediately seals on first interaction.

// 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 {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// IAttackRegistry.ContractState enum:
// NOT_DEPLOYED=0, NEW_DEPLOYMENT=1, ATTACK_REQUESTED=2, UNDER_ATTACK=3,
// PROMOTION_REQUESTED=4, PRODUCTION=5, CORRUPTED=6
contract MockToken is ERC20 {
constructor() ERC20("Mock","MCK") {}
function mint(address to, uint256 amt) external { _mint(to, amt); }
}
contract MockRegistry {
uint8 public state;
// safeHarborRegistry interface
function getAttackRegistry() external view returns (address) { return address(this); }
function isAgreementValid(address) external pure returns (bool) { return true; }
// attackRegistry interface — ABI-compatible with IAttackRegistry.ContractState (enum = uint8)
function getAgreementState(address) external view returns (uint8) { return state; }
function setState(uint8 _s) external { state = _s; }
}
contract MockAgreement {
address public owner;
constructor(address _o) { owner = _o; }
function isContractInScope(address) external pure returns (bool) { return true; }
}
contract TerminalPoolTest is Test {
ConfidencePoolFactory factory;
MockToken token;
MockRegistry registry;
address sponsor = address(0x111);
function setUp() public {
token = new MockToken();
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);
}
// Test 1: PRODUCTION (state = 5) — agreement survived, risk is over
function test_CreatePool_PRODUCTION_PoolIsImmediatelyDead() public {
registry.setState(5); // PRODUCTION
vm.prank(sponsor);
MockAgreement agreement = new MockAgreement(sponsor);
address[] memory scope = new address[](1);
scope[0] = address(0x999);
vm.prank(sponsor);
address poolAddr = factory.createPool(
address(agreement), address(token),
block.timestamp + 60 days, 1 ether, sponsor, scope
);
ConfidencePool pool = ConfidencePool(poolAddr);
// First interaction seals riskWindowEnd immediately
pool.pokeRiskWindow();
assertGt(pool.riskWindowEnd(), 0, "riskWindowEnd sealed on first interaction");
// Staking is now permanently blocked
token.mint(address(this), 1 ether);
token.approve(poolAddr, 1 ether);
vm.expectRevert(IConfidencePool.StakingClosed.selector);
pool.stake(1 ether);
}
// Test 2: CORRUPTED (state = 6) — agreement was breached, pool is useless
function test_CreatePool_CORRUPTED_PoolIsImmediatelyDead() public {
registry.setState(6); // CORRUPTED
vm.prank(sponsor);
MockAgreement agreement = new MockAgreement(sponsor);
address[] memory scope = new address[](1);
scope[0] = address(0x999);
vm.prank(sponsor);
address poolAddr = factory.createPool(
address(agreement), address(token),
block.timestamp + 60 days, 1 ether, sponsor, scope
);
// Factory accepted a CORRUPTED agreement without revert
assertTrue(poolAddr != address(0), "BUG: pool created for already-corrupted agreement");
ConfidencePool pool = ConfidencePool(poolAddr);
pool.pokeRiskWindow(); // seals riskWindowEnd
assertGt(pool.riskWindowEnd(), 0, "riskWindowEnd sealed on first interaction");
}
}

Recommended Mitigation

After the existing isAgreementValid check, query the live ContractState from the attack registry and revert if the agreement is already terminal. Use the IAttackRegistry.ContractState enum directly instead of raw uint8 values to make the intent explicit and avoid mapping errors:

// src/ConfidencePoolFactory.sol
+ import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
function createPool(...) external whenNotPaused returns (address pool) {
...
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
+
+ // Reject agreements already in a terminal state — pools for such agreements
+ // are dead-on-arrival: staking is permanently blocked on first interaction.
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ IAttackRegistry.ContractState liveState =
+ IAttackRegistry(attackRegistry).getAgreementState(agreement);
+ if (
+ liveState == IAttackRegistry.ContractState.PRODUCTION ||
+ liveState == IAttackRegistry.ContractState.CORRUPTED
+ ) revert InvalidAgreement();
+
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
...
}

IAttackRegistry.ContractState values for reference — PRODUCTION = 5, CORRUPTED = 6, PROMOTION_REQUESTED = 4 (still active, should not be blocked):

NOT_DEPLOYED = 0
NEW_DEPLOYMENT = 1
ATTACK_REQUESTED = 2
UNDER_ATTACK = 3
PROMOTION_REQUESTED = 4 ← still actively under attack, pool creation valid
PRODUCTION = 5 ← terminal: agreement survived
CORRUPTED = 6 ← terminal: agreement was breached

This reuses the existing InvalidAgreement() error (defined in IConfidencePool.sol line 59), requiring no new interface additions.

Support

FAQs

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

Give us feedback!