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

withdraw() locks stakers with zero bonus when the registry skips straight to PRODUCTION, never passing through UNDER_ATTACK

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: withdraw() lets a staker exit while the protocol is not yet dangerous. The docs promise this as a fairness guarantee — docs/DESIGN.md §9: "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 deal is a fair trade: you lose the exit EXACTLY when you start earning the premium.

  • The bug: the exit can close without risk ever materializing. When it does, the staker is locked in and earns zero bonus — both halves of the trade fail at once.

  • Why: withdraw() allows the exit only in three registry states — NOT_DEPLOYED, NEW_DEPLOYMENT, ATTACK_REQUESTED. Everything else, including PRODUCTION, permanently disables it. The docs describe this as "disabled from UNDER_ATTACK onward" (§9), which assumes the registry always walks ... → UNDER_ATTACK → ... → PRODUCTION. It does not. The registry can reach PRODUCTION while completely skipping UNDER_ATTACK, and when it does riskWindowStart stays 0, so _bonusShare pays nothing.

// ConfidencePool.sol — withdraw()
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
// @> PRODUCTION is not in the allowed set, so reaching it -- even WITHOUT ever passing
// @> through UNDER_ATTACK -- permanently disables withdraw. riskWindowStart stays 0,
// @> so _bonusShare() also pays zero. Locked in, earning nothing, never at risk.
) {
revert WithdrawsDisabled();
}
  • The team already knows the registry can skip the active-risk statesprotocol-readme.md:31: "If the registry transitions straight from NEW_DEPLOYMENT to a terminal state without anyone observing an active-risk state, riskWindowStart never seals." But they only ever connect that skip-path to the bonus ("no observed risk"). They never connect it to withdraw closing. §9 asserts the opposite. The two statements cannot both hold.

  • Two ways the registry reaches PRODUCTION without UNDER_ATTACK (both verified in AttackRegistry.sol, the real dependency):

    Route A — the clock, no transaction, no event. AttackRegistry.sol:872:

    if (block.timestamp >= info.deadlineTimestamp) { return ContractState.PRODUCTION; }

    _registerAgreement sets deadlineTimestamp = block.timestamp + PROMOTION_WINDOW (14 days) with attackApproved: false. The check sits after the attackApproved branch, so it fires only for a request the DAO never approved. The sponsor requests the attack phase → the DAO does not approve within 14 days → the state becomes PRODUCTION by the passage of time alone. No transaction, no event to observe.

    Route B — sponsor, one atomic tx. AttackRegistry.goToProduction takes NOT_DEPLOYED → PRODUCTION in a single call. It is owner-gated (_validateAndPrepareAgreement, AttackRegistry.sol:752-755: msg.sender != agreementOwner reverts). ConfidencePoolFactory.createPool already enforces IAgreement(agreement).owner() == msg.sender, so the pool sponsor is always the agreement owner — always authorized to call it. Stakers cannot front-run a single atomic tx.

  • This is structural, not an edge case. _MIN_EXPIRY_LEAD is 30 days; PROMOTION_WINDOW is 14 days. So the auto-promote deadline (Route A) always lands at least 16 days before the earliest legal expiry. Every pool whose agreement sits in ATTACK_REQUESTED without DAO approval has a mandatory lock-in window built into the constants.

Risk

Likelihood: Medium

  • Route A requires no malicious actor at all — only DAO inaction for 14 days on an attack request that was made in good faith. The sponsor need do nothing. This clears the "admin acting against documented intent" bar, because nobody acts against intent.

  • Route B is a deliberate sponsor action, free and unfront-runnable, that directly realizes the "grief" §9 says is impossible.

  • The staker's only defense (pokeRiskWindow / poll deadlineTimestamp) requires knowing to watch a timestamp on an external contract; §6's stated defense ("sealable by anyone via permissionless pokeRiskWindow() during the active-risk interval") is inert here, because on both routes there is no active-risk interval — not one block of it.

Impact: Medium

  • Staker capital is locked from the PRODUCTION transition until the moderator flags SURVIVED or expiry arrives (sponsor-chosen pre-stake, bounded only by uint32 → year 2106), earning zero bonus the whole time.

  • On Route B the sponsor can then call the permissionless sweepUnclaimedBonus() and reclaim the entire bonus pot to recoveryAddress — funding the pool costs them nothing net, while stakers are locked with no yield and no risk underwritten.

  • Principal is ultimately recoverable (it is not stolen), which is what caps this at Medium rather than High — the harm is locked capital + denied premium, not lost principal.

Proof of Concept

Paste both functions into test/unit/ConfidencePool.t.sol (anywhere inside the existing contract ConfidencePoolTest block) and run:

forge test --match-path test/unit/ConfidencePool.t.sol --match-test "test_PoC_withdraw" -vv

No new imports or setup needed — that file already imports IConfidencePool, IAttackRegistry and PoolStates, and extends BaseConfidencePoolTest.

Scope note (important): the pool's behavior is proven directly — given a registry reporting PRODUCTION with no prior active-risk observation, withdraw reverts permanently and the bonus is zero. How the registry reaches PRODUCTION without UNDER_ATTACK is an out-of-scope dependency fact, cited from AttackRegistry.sol above (Routes A/B), not executed here — the real AttackRegistry pins pragma solidity 0.8.34 while this project pins 0.8.26, so it cannot be compiled into this suite (which is why the repo vendors only its interfaces). The test simulates the transition via MockAttackRegistry, which faithfully represents "registry reports PRODUCTION" from the pool's view.

Both functions matter. The first proves the harm. The second is the control: when risk genuinely materializes, the exit closes and the premium is paid (STAKE + BONUS). So "the exit closes" is not the bug — exit-closed-without-premium is.

/// @notice PoC — the exit closes and the bonus is zero although risk never materialized.
function test_PoC_withdraw_exitClosesWhileRiskNeverMaterialized() external {
uint256 stakeAmt = 100 * ONE;
uint256 bonusAmt = 50 * ONE;
// DESIGN.md section 9: the exit is open across every pre-attack state.
_stake(alice, stakeAmt);
_withdraw(alice);
assertEq(pool.eligibleStake(alice), 0, "exit open in NEW_DEPLOYMENT");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_stake(alice, stakeAmt);
_withdraw(alice);
assertEq(pool.eligibleStake(alice), 0, "exit open in ATTACK_REQUESTED");
// Alice commits for real; Bob funds the bonus she is locking up for.
_stake(alice, stakeAmt);
_contributeBonus(bob, bonusAmt);
// The registry reaches PRODUCTION WITHOUT ever passing through UNDER_ATTACK.
// Route A: happens with ZERO transactions once deadlineTimestamp elapses
// (AttackRegistry.sol:872). Route B: the sponsor's own goToProduction in one tx.
// Either way the pool observes the same thing: a registry reporting PRODUCTION.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
// Risk NEVER materialized: no active-risk state was ever observed.
pool.pokeRiskWindow(); // seals riskWindowEnd only; PRODUCTION is terminal, not active-risk
assertEq(pool.riskWindowStart(), 0, "risk NEVER materialized -- UNDER_ATTACK never occurred");
// DESIGN.md section 9 promise: "stakers can freely exit until risk materializes." FALSIFIED.
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// And the premium section 9 promises in exchange for the lock is zero.
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, stakeAmt, "VULN: principal only -- zero bonus for a locked staker"
);
// The bonus Alice was locked up for sweeps to the sponsor-controlled recoveryAddress.
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), bonusAmt, "entire bonus pot to recoveryAddress");
}
/// @notice Control. When risk genuinely materializes the exit closes -- but the premium is
/// paid, which is what makes the lock fair. Isolates "exit closed WITHOUT premium" as the bug.
function test_PoC_withdraw_control_riskMaterializesPremiumEarned() external {
uint256 stakeAmt = 100 * ONE;
uint256 bonusAmt = 50 * ONE;
_stake(alice, stakeAmt);
_contributeBonus(bob, bonusAmt);
// Risk actually materializes.
_passThroughUnderAttack();
assertGt(pool.riskWindowStart(), 0, "risk materialized");
// Exit closes -- correct and intended per DESIGN.md section 9.
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// And the premium is actually paid, which is what makes the lock fair.
vm.warp(block.timestamp + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
pool.claimSurvived();
assertEq(token.balanceOf(alice), stakeAmt + bonusAmt, "premium earned: the section 9 trade holds here");
}

Result:

Ran 2 tests for test/unit/ConfidencePool.t.sol:ConfidencePoolTest
[PASS] test_PoC_withdraw_control_riskMaterializesPremiumEarned()
[PASS] test_PoC_withdraw_exitClosesWhileRiskNeverMaterialized()
Suite result: ok. 2 passed; 0 failed; 0 skipped

Recommended Mitigation

Allow the exit when the registry is PRODUCTION and no risk window was ever observed — a terminal survival with no risk borne has nothing to settle. This does not re-open the CORRUPTED escape that DESIGN.md §5 / PR#63 closed, because that path is gated on state == CORRUPTED, which stays excluded.

--- a/src/ConfidencePool.sol
+++ b/src/ConfidencePool.sol
@@ function withdraw() external nonReentrant {
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
- && state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
+ && state != IAttackRegistry.ContractState.ATTACK_REQUESTED
+ // A terminal PRODUCTION with no observed risk window means the agreement
+ // survived without ever being attackable -- there is no risk to settle, so
+ // the staker must still be able to exit. CORRUPTED stays excluded.
+ && state != IAttackRegistry.ContractState.PRODUCTION)
) {
revert WithdrawsDisabled();
}

Note the riskWindowStart != 0 latch remains the primary guard: once any risk window was observed, the exit stays closed regardless of state, so this only re-opens the exit in the exact no-risk-ever case the bug is about.

Support

FAQs

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

Give us feedback!