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

createPool does not verify the agreement is registered in the AttackRegistry, so a pool on a valid-but-unregistered agreement is permanently inert and routes bonus contributions to recoveryAddress

Author Revealed upon completion

Root + Impact

Root cause: createPool validates that the agreement is factory-deployed (isAgreementValid) but never checks that the agreement is registered/live in the AttackRegistry, so getAgreementState can be permanently NOT_DEPLOYED.

Impact: such a pool is inert — it can never observe risk or a terminal state, the moderator can never flag SURVIVED/CORRUPTED (both require a terminal registry state), and only the EXPIRED backstop is reachable. Bonus contributions are then swept to the sponsor-controlled recoveryAddress while the coverage the pool advertises can never fire; an unprivileged bonus contributor funds a no-op-by-construction pool.

Description

  • Normal behavior: a pool tracks a live agreement whose contracts are registered in the AttackRegistry; the registry drives the SURVIVED/CORRUPTED/EXPIRED resolution.

  • The issue: createPool only requires isAgreementValid(agreement) (factory-deployed) and IAgreement(agreement).owner() == msg.sender. It does NOT require the agreement's contracts to be registered in the AttackRegistry. getAgreementState returns NOT_DEPLOYED for an unregistered agreement, so flagOutcome (which requires a terminal state) always reverts, and the pool can only ever resolve EXPIRED via claimExpired. With riskWindowStart == 0, all bonus is swept to recoveryAddress — the sponsor captures bonus contributors' funds and no coverage ever triggers.

// src/ConfidencePoolFactory.sol — createPool
@> if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement(); // factory-deployed only
@> if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
// no check that the agreement's contracts are registered / that getAgreementState is meaningful

Risk

Likelihood:

  • A sponsor creates a pool on a factory-valid agreement whose contracts are not (yet) registered in the AttackRegistry — by mistake, or to capture bonus contributions with no real coverage obligation.

  • Unprivileged bonus contributors fund the pool's incentive without an easy on-chain signal that the agreement is inert.

Impact:

  • The pool can never resolve SURVIVED or CORRUPTED; only EXPIRED is reachable.

  • Bonus contributions are swept to the sponsor's recoveryAddress; the advertised coverage never had any way to fire. Stakers recover principal only (no direct principal loss).

Proof of Concept

Drop this into test/unit/ConfidencePool.inertPool.t.sol (extends the repo's BaseConfidencePoolTest) and run forge test --match-contract InertPool -vv:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract ConfidencePoolInertPoolTest is BaseConfidencePoolTest {
function test_inertPoolCanOnlyResolveExpired_bonusGoesToRecovery() public {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED); // unregistered
_stake(alice, 100 ether);
_contributeBonus(carol, 40 ether);
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0)); // moderator can never flag
vm.warp(block.timestamp + 32 days);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice), 100 ether); // principal only
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 40 ether); // bonus captured by recovery, coverage inert
}
}

Recommended Mitigation

In createPool (or initialize), require the agreement to be live in the AttackRegistry — e.g. reject creation when getAttackRegistry() == address(0) or when getAgreementState(agreement) reads a non-participating state such as NOT_DEPLOYED, so a pool cannot be created against an agreement that can never resolve to a real outcome.

Support

FAQs

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

Give us feedback!