FoundrySolidityLayer 2
7.25 ETH
View results
Submission Details
Severity: medium
Valid

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

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

Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 month ago
inallhonesty Lead Judge 22 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
inallhonesty Lead Judge 22 days ago
Submission Judgement Published
Invalidated
Reason: Design choice

Appeal created

ren Submitter
22 days ago
inallhonesty Lead Judge
19 days ago
inallhonesty Lead Judge 18 days ago
Submission Judgement Published
Validated
Assigned finding tags:

riskWindowStart never seals under instant DAO promotion, zeroing bonus for withdraw-locked stakers who bore real risk

Impact - Medium Principal comes back in full, so what's lost is the advertised premium and the use of the capital while it sits locked. The exit dies as soon as the registry reads PRODUCTION, which fails withdraw's state check on its own, while _bonusShare pays zero because no active-risk state was ever seen. DESIGN.md #9 sells the exit-lock as the moment the premium starts accruing; here both halves fail at once, and the bonus sweeps to the sponsor's recoveryAddress. Likelihood - Medium Two routes get here and only one needs a bad actor. Leave an attack request unapproved for fourteen days and the registry computes PRODUCTION from the clock alone, with no transaction and no event to watch for. Against a thirty-day minimum expiry, that can land mid-term. The other route is the sponsor calling goToProduction, one atomic transaction with no DAO step. Stakers can exit up to either moment, which holds this at Medium, but neither gives warning.

Support

FAQs

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

Give us feedback!