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

Staker capital is trapped at PRODUCTION, the exit closes while the pool's own records show no risk was borne

Author Revealed upon completion

Description

  • docs/DESIGN.md §9 states the staker's bargain as a biconditional, then denies the failure outright: "a staker only forfeits the exit option once risk has actually materialized, which is exactly when they begin earning the risk premium." ... "A sponsor cannot grief stakers by keeping the agreement out of attackable mode, stakers can freely exit until risk materializes"

The code does not implement that biconditional: the exit gate reads live registry state, while the pool's record of risk is the one-way latch

// ConfidencePool.sol:293-300 - closes on any state outside the allowlist
if (
riskWindowStart != 0
@> || (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
// ConfidencePool.sol:833-835 - but the latch only ever opens on an active-risk state
function _isActiveRiskState(IAttackRegistry.ContractState s) internal pure returns (bool) {
@> return s == IAttackRegistry.ContractState.UNDER_ATTACK || s == IAttackRegistry.ContractState.PROMOTION_REQUESTED;
}
  • The two sets are not complements. PRODUCTION sits in the gap: outside the withdraw allowlist (exit closed) yet outside _isActiveRiskState, so riskWindowStart stays 0 forever and _bonusShare returns 0 (:699). pokeRiskWindow cannot help - there is no active-risk transition to observe, and the state is irreversible (AttackRegistry.sol:744)

  • Two free upstream paths reach PRODUCTION with no active-risk state:

    1. AttackRegistry.goToProduction:282 - "Skip attack mode and go directly to production... No fee or bond is collected." Callable by the agreement owner, whom createPool:82,99 forces to be the pool's owner, where the scope contracts are BattleChainDeployer-verified (s_authorizedOwner:289). It needs the agreement unregistered, i.e. the NOT_DEPLOYED state a pool may be created against, since isAgreementValid only checks it was factory-created. This is the scenario §9 denies: it is keeping the agreement out of attackable mode

    2. No actor at all. Registering for the attack phase - requestUnderAttack:230 for BattleChainDeployer-verified contracts, or requestUnderAttackForUnverifiedContracts:254 otherwise - sets deadlineTimestamp = now + 14 days via _registerAgreement:794. This is the sponsor's normal, honest flow. If the DAO then never calls approveAttack, _getAgreementState:871-873 returns PRODUCTION - checked after attackApproved:867, so the latch never seals. The state flips with no transaction and no event

  • Not claimed: the zero bonus is correct (no risk was borne, §5's rule applies) and the sponsor reclaiming their unearned bonus is intended. The defect is solely the closed exit - a pairing §9 and §5 each describe separately but never together

Risk

Severity: Medium - Medium Impact x High Likelihood

Likelihood: High. Path 2 requires nobody to act: any agreement whose attack request the DAO does not approve within 14 days auto-promotes, trapping every staker. No attacker, no cost, no transaction. Path 1 is one free sponsor call available at any moment after stakers deposit, including the same block, so stakers cannot react

Impact: Medium. Capital is frozen with no exit from the moment PRODUCTION is observed until the moderator flags SURVIVED, or until expiry if the moderator is idle. Principal is fully recoverable at resolution, which bounds this below a loss. The duration sits inside the pool term (expiry is settable up to type(uint32).max before the first stake) and stakers earn zero throughout, because the pool records no risk. The harm is a forced zero-yield loan: the staker gave up the exit and got nothing in return, which is the exact inverse of the §9 bargain

Proof of Concept

Self-contained against the existing BaseConfidencePoolTest harness, no RPC needed. PASSES:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract H1WithdrawProductionGapTest is BaseConfidencePoolTest {
function test_productionClosesExitThoughNoRiskWasBorne() public {
_stake(alice, 1000 * ONE);
vm.prank(alice);
pool.withdraw();
assertEq(token.balanceOf(alice), 1000 * ONE, "exit open pre-attack");
vm.startPrank(alice);
token.approve(address(pool), 1000 * ONE);
pool.stake(1000 * ONE);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), 0, "no risk was ever borne");
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
vm.prank(alice);
pool.withdraw();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice), 1000 * ONE);
}
}

Path 2's reachability is verifiable from the registry code cited above. A fork run against the live BattleChain testnet (chainId 627) confirms it end-to-end: minting a real agreement via the live AgreementFactory, registering it via requestUnderAttackForUnverifiedContracts, then reaching PRODUCTION with no further action from anyone, purely because the DAO never approves

[PASS] testProductionReachedWithoutAttackTrapsStakerCapital()
AgreementFactory.create() -> live agreement, NOT_DEPLOYED
withdraw() pre-attack -> succeeds // the §9 bargain holds
requestUnderAttack -> ATTACK_REQUESTED
withdraw() in ATTACK_REQUESTED -> succeeds // still open
warp(+14d), DAO never approves -> PRODUCTION, NO TRANSACTION
pokeRiskWindow() -> riskWindowStart still 0 // no risk was borne
withdraw() -> revert WithdrawsDisabled // trapped anyway

Recommended Mitigation

Gate withdraw on the pool's own risk record - the invariant §9 already states. The live-state clause is redundant, because _observePoolState() on line 290 seals the latch before the check. Retaining CORRUPTED preserves §5's principal-settlement gating.

IAttackRegistry.ContractState state = _observePoolState();
- // `riskWindowStart` is the pool's one-way record that risk has materialised;
- // gate on it so an upstream registry rewind cannot re-open withdrawals.
- if (
- riskWindowStart != 0
- || (state != IAttackRegistry.ContractState.NOT_DEPLOYED
- && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
- && state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
- ) {
- revert WithdrawsDisabled();
- }
+ // The latch already subsumes every active-risk state. CORRUPTED is retained: principal
+ // settles through the resolution path, not the exit. A terminal PRODUCTION with no observed
+ // risk window owes no premium, so closing the exit there forfeits it for nothing.
+ if (riskWindowStart != 0 || state == IAttackRegistry.ContractState.CORRUPTED) {
+ revert WithdrawsDisabled();
+ }

§9's closing paragraph also needs correcting: goToProduction exists precisely to let a sponsor skip attackable mode

Support

FAQs

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

Give us feedback!