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

Missing agreement-lifecycle gate in `initialize`

Author Revealed upon completion

Root + Impact

Description

  • A ConfidencePool is only meant to exist during the agreement's commitment phase (NOT_DEPLOYED / NEW_DEPLOYMENT) — the exact pair the pool treats as "scope still open." Scope is chosen up front and locks one-way the moment _observePoolState first sees any other state, on the assumption that scope is committed before anyone knows how risk plays out.

  • initialize performs no lifecycle check. Its only agreement-side validation, isAgreementValid, is a provenance check (was this made by the factory), not a state gate — so a pool can be created against an agreement in any of the five non-commitment states. The impact splits on whether the state permits deposits (_assertDepositsAllowed):

    • ATTACK_REQUESTED / UNDER_ATTACK — fund-affecting. Deposits are open, so real money enters and the creator picks the covered-account list after learning which contract is under attack. They can exclude that contract; when the agreement is later marked CORRUPTED, the moderator resolves SURVIVED via the "breach outside this pool's scope" branch (https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L334-L340), refunds stakers in full, and pays whitehats nothing.

    • PROMOTION_REQUESTED / PRODUCTION / CORRUPTED Deposits are gated shut, so no funds at risk, but the pool still deploys, advertises a scope, and reports UNRESOLVED — misleading indexers/UIs and enabling spam.

// src/ConfidencePool.sol — initialize()
// aderyn-fp-next-line(reentrancy-state-change)
@> if (!IBattleChainSafeHarborRegistry(safeHarborRegistry_).isAgreementValid(agreement_)) {
@> revert InvalidAgreement(); // provenance only — accepts an agreement in ANY lifecycle state
}
agreement = agreement_;
// ...
// @> No read of getAgreementState(agreement_) anywhere in initialize. The scope committed below
// @> is only sound in the commitment phase; in the five other states it is either a hindsight
// @> commitment over live risk (ATTACK_REQUESTED, UNDER_ATTACK) or a misleading zombie pool.
@> _replaceScope(accounts);

Risk

Likelihood:

Reasonably likely for the fund-affecting states — any creator can deploy at will; the only precondition is the agreement being in ATTACK_REQUESTED / UNDER_ATTACK, which requires no race or privilege. The escape uses only intended flows (initialize → stake → flagOutcome(SURVIVED)); nothing reverts.

Impact:

  • Live-pool abuse denies whitehats a bounty they staked/hunted against while refunding stakers in full — value redirected by a decision made with hindsight.

  • Zombie pools cause no fund loss but misrepresent on-chain state to participants and indexers, and enable spam.

Proof of Concept

test/unit/ConfidencePool.deployStateGate.t.sol (run forge test --match-path "deployStateGate"). The matrix test walks all seven states, proving initialize never reverts and mapping which states let funds in:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC: `initialize` never reads the agreement's lifecycle state. `isAgreementValid` is a
/// provenance check (made-by-the-factory), not a state gate, so a pool can be created in any of the
/// five non-commitment states. Scope is meant to be committed up front and locks on the first
/// observation of risk; creating a pool mid-attack lets the creator commit scope with hindsight.
///
contract ConfidencePoolDeployStateGateTest is BaseConfidencePoolTest {
/// @dev Deploy a fresh pool against the shared agreement/registry. Registry state is whatever
/// the caller staged beforehand.
function _deployPoolNow() internal returns (ConfidencePool p) {
p = ConfidencePool(Clones.clone(address(new ConfidencePool())));
p.initialize(
agreement, address(token), address(safeHarborRegistry), moderator,
block.timestamp + 31 days, ONE, recovery, address(this), _defaultScope()
);
}
/// @dev Root cause: `initialize` is unguarded in EVERY state. Deposit reachability
/// (`_assertDepositsAllowed`) is the line between a fund-affecting "live" pool and a
/// misleading-but-harmless "zombie" pool. Only the two commitment states SHOULD be allowed.
function testDeployUnguardedAcrossAllStates() external {
IAttackRegistry.ContractState[7] memory states = [
IAttackRegistry.ContractState.NOT_DEPLOYED, // commitment (intended)
IAttackRegistry.ContractState.NEW_DEPLOYMENT, // commitment (intended)
IAttackRegistry.ContractState.ATTACK_REQUESTED, // live + hindsight
IAttackRegistry.ContractState.UNDER_ATTACK, // live + hindsight
IAttackRegistry.ContractState.PROMOTION_REQUESTED, // zombie
IAttackRegistry.ContractState.PRODUCTION, // zombie
IAttackRegistry.ContractState.CORRUPTED // zombie
];
bool[7] memory depositsAllowed = [true, true, true, true, false, false, false];
for (uint256 i; i < states.length; ++i) {
attackRegistry.setAgreementState(states[i]);
// (a) initialize never reverts, regardless of lifecycle state.
ConfidencePool p = _deployPoolNow();
assertEq(uint256(p.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "always UNRESOLVED");
assertEq(p.getScopeAccounts().length, 1, "always advertises a scope");
// (b) deposit reachability splits live pools from zombie pools.
address user = address(uint160(0xD00D0000 + i));
token.mint(user, ONE);
vm.startPrank(user);
token.approve(address(p), ONE);
if (depositsAllowed[i]) {
p.stake(ONE);
assertEq(p.totalEligibleStake(), ONE, "live pool took funds");
} else {
vm.expectRevert(IConfidencePool.StakingClosed.selector);
p.stake(ONE);
}
vm.stopPrank();
}
}
/// @dev Value impact of the live case. Agreement is UNDER_ATTACK at creation; the creator
/// commits a scope that excludes the contract being exploited, takes stakes, and when the
/// agreement is marked CORRUPTED the moderator still resolves SURVIVED via the CORRUPTED-accepted
/// branch ("breach out of this pool's scope", ConfidencePool.sol:334-340). Stakers are refunded
/// in full; whitehats who exploited the excluded contract get nothing.
function testHindsightScopeYieldsSurvivedDespiteCorruption() external {
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
ConfidencePool live = _deployPoolNow();
token.mint(alice, 10 * ONE);
vm.startPrank(alice);
token.approve(address(live), 10 * ONE);
live.stake(10 * ONE); // stakes AND locks the hindsight scope in one shot
vm.stopPrank();
assertEq(token.balanceOf(alice), 0, "alice fully staked");
assertTrue(live.scopeLocked(), "hindsight scope frozen on first observation");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
live.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(live.outcome()), uint256(PoolStates.Outcome.SURVIVED), "resolved SURVIVED");
vm.prank(alice);
live.claimSurvived();
assertEq(token.balanceOf(alice), 10 * ONE, "staker fully refunded despite a real corruption");
}
}

Results:

[PASS] testDeployUnguardedAcrossAllStates() (gas: 21792141)
[PASS] testHindsightScopeYieldsSurvivedDespiteCorruption() (gas: 3290468)

Recommended Mitigation

One check closes all five bad states. agreement is assigned before _replaceScope, so _getAgreementState() is already callable:

agreement = agreement_;
stakeToken = IERC20(stakeToken_);
safeHarborRegistry = IBattleChainSafeHarborRegistry(safeHarborRegistry_);
+
+ // A pool may only be created during the commitment phase — the same NOT_DEPLOYED /
+ // NEW_DEPLOYMENT pair the scope-lock uses. Rejects all five non-commitment states.
+ IAttackRegistry.ContractState state = _getAgreementState();
+ if (
+ state != IAttackRegistry.ContractState.NOT_DEPLOYED
+ && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
+ ) {
+ revert AgreementNotInCommitmentPhase();
+ }

Support

FAQs

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

Give us feedback!