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

goToProduction sends the agreement straight to PRODUCTION, stripping the entire bonus from stakers who can no longer exit, despite the protocol surviving

Author Revealed upon completion

Root + Impact

src/ConfidencePool.sol — the withdraw() gate and the bonus-eligibility premise are inconsistent for the direct-to-PRODUCTION path.

DESIGN.md §9 justifies "no observed risk -> no bonus" on the premise that a staker only forfeits the exit option once risk materializes (riskWindowStart == 0 is assumed to imply the staker could always have exited). That is false for goToProduction: the agreement owner (== the pool sponsor, per createPool) can send the agreement NEW_DEPLOYMENT -> PRODUCTION, skipping the attack phase. UNDER_ATTACK is never observed, so riskWindowStart stays 0, yet withdraw() is disabled once the state leaves the pre-attack set. Stakers are locked in, _bonusShare returns 0 for all of them, and the whole bonus pool is swept to recoveryAddress (the sponsor).

Impact: every staker loses their entire bonus share (the yield they staked to earn) even though the protocol survived; the bonus is diverted to the sponsor. A malicious sponsor can trigger this deliberately: seed a bonus to attract stakers, then call goToProduction to claw it back.

Description

withdraw gate blocks every non pre-attack state, independent of riskWindowStart:

if (
riskWindowStart != 0
|| (state != NOT_DEPLOYED && state != NEW_DEPLOYMENT && state != ATTACK_REQUESTED)
) revert WithdrawsDisabled();

@> PRODUCTION is not allowed, so withdraw reverts once PRODUCTION is reached, even though riskWindowStart is still 0.

bonus share zeroes out when no risk window opened:

// _bonusShare(...)
if (riskWindowStart == 0) return 0;

@> every staker's bonus share is 0, so sweepUnclaimedBonus sends the full bonus to recoveryAddress.

Sequence:

  1. Agreement NEW_DEPLOYMENT. Bonus contributor funds the pool; stakers deposit, expecting principal + bonus if the protocol survives.

  2. Sponsor (agreement owner) calls goToProduction: NEW_DEPLOYMENT -> PRODUCTION, skipping UNDER_ATTACK. _observePoolState never opens riskWindowStart.

  3. withdraw now reverts (PRODUCTION not in pre-attack set): stakers cannot exit.

  4. Outcome resolves SURVIVED (moderator or claimExpired). claimSurvived pays principal + _bonusShare == 0.

  5. sweepUnclaimedBonus transfers the entire bonus to the sponsor's recoveryAddress.

§9's fairness argument ("staker could exit") does not hold: the exit is revoked without risk ever materializing, yet the staker is denied the bonus.

Risk

Likelihood: Medium — goToProduction is a documented, sponsor-reachable registry path for protocols that skip the attack phase; no special timing/front-run needed.

Impact: Medium — total loss of the bonus/yield for all stakers, redirected to the sponsor.

Proof of Concept

forge test --match-contract GoToProductionBonusTheftTest -vv passes against the in-scope contracts:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract GoToProductionBonusTheftTest is BaseConfidencePoolTest {
function test_goToProduction_stripsBonusAndRevokesExit() external {
_contributeBonus(makeAddr("thirdPartySponsor"), 500 * ONE);
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
assertEq(pool.riskWindowStart(), 0, "risk window never opened");
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "principal only, NO bonus");
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery) - recoveryBefore, 500 * ONE, "bonus diverted to sponsor");
}
}

Recommended Mitigation

Align the bonus premise with the withdraw gate: when the pool resolves SURVIVED/EXPIRED with riskWindowStart == 0 but withdraw was already disabled (state advanced to PRODUCTION), distribute the bonus amount-weighted to stakers (the fallback already used when globalScore == 0) instead of returning 0 and sweeping to recovery. Alternatively, treat reaching PRODUCTION as opening-and-closing the risk window in one observation (riskWindowStart = riskWindowEnd = block.timestamp) so stakers earn the bonus for the term they were locked in.

Support

FAQs

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

Give us feedback!