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

Stakers Depositing During `UNDER_ATTACK` Are Permanently Locked With Near-Zero Bonus Upside and Full Principal Risk

Author Revealed upon completion

Description

_assertDepositsAllowed deliberately allows deposits during UNDER_ATTACK while blocking them during PROMOTION_REQUESTED, PRODUCTION, and CORRUPTED. The rationale is that UNDER_ATTACK deposits are voluntary risk capital where the k=2 weighting crushes bonus to ~zero, making late-join unattractive without needing to block it.

However, three mechanics combine to create a trap:

  1. Withdrawals are permanently disabled once riskWindowStart != 0 (set when UNDER_ATTACK is first observed). A staker depositing during UNDER_ATTACK can never exit only resolution returns their funds.

  2. The k=2 bonus is ~zero because the staker's effective entry time is max(block.timestamp, riskWindowStart) ≈ now, so ( (T - \text{entry})^2 \approx 0 ) for any near-term resolution.

  3. Full principal loss on CORRUPTED: if the agreement is breached and the moderator flags CORRUPTED, the staker's entire deposit sweeps to recoveryAddress (bad-faith) or the named attacker (good-faith).

The staker bears full downside risk with negligible upside. The contract provides no warning event or other mechanism for frontends to surface this risk to users before they stake.

// @> UNDER_ATTACK is intentionally absent from the blocked-states list
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (state == PROMOTION_REQUESTED || state == PRODUCTION || state == CORRUPTED) {
revert StakingClosed();
}
}
// @> During UNDER_ATTACK: riskWindowStart is already non-zero, so withdraw reverts
if (riskWindowStart != 0 || (state != NOT_DEPLOYED && state != NEW_DEPLOYMENT
&& state != ATTACK_REQUESTED)) {
revert WithdrawsDisabled();
}

Risk

Likelihood: Low

  • UNDER_ATTACK is a normal operating mode that typically lasts for extended periods; rapid transitions to terminal states are unlikely

  • Stakers can read riskWindowStart (public getter) and the live registry state on-chain before calling stake()

  • The behavior is documented in DESIGN.md §3 as an intentional design decision

  • Only affects stakers who do not check on-chain state before depositing

Impact:

  • Immediate CORRUPTED after deposit → staker loses 100% of principal

  • Immediate SURVIVED after deposit → staker recovers principal but earns ~zero bonus (capital was locked for no upside)

  • Principal lock duration is unbounded while registry remains in UNDER_ATTACK

Proof of Concept

The PoC demonstrates the worst-case scenario: a staker deposits during UNDER_ATTACK, the registry immediately transitions to CORRUPTED, and the staker's entire principal is swept to recoveryAddress with no bonus.

function testUnderAttackDepositThenImmediateCorruptedLosesEverything() external {
// Registry enters UNDER_ATTACK → risk window opens → withdrawals permanently locked
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), 0);
// Alice stakes during UNDER_ATTACK (allowed). Entry time ≈ now, bonus ≈ zero.
_stake(alice, 100 * ONE);
// Withdrawal is blocked — riskWindowStart != 0 latch is set
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// Registry transitions to CORRUPTED immediately after
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Alice's entire 100 ONE is swept to recovery. She gets nothing.
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 * ONE);
assertEq(token.balanceOf(alice), 0);
}

A companion test in test/poc/PoC_UnderAttackDepositTrap.t.sol also verifies that with multiple stakers, the late UNDER_ATTACK entrant's bonus is crushed by the k=2 formula compared to an early staker who bore more at-risk time.

Full runnable PoC at test/poc/PoC_UnderAttackDepositTrap.t.sol — 4 tests, all pass.

Recommended Mitigation

Emit a warning event when a stake occurs during UNDER_ATTACK, giving frontends and indexers a standard mechanism to surface the elevated risk:

+ event HighRiskStake(address indexed staker, uint256 amount, uint32 riskWindowStart);
function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ... existing checks ...
_assertDepositsAllowed(_observePoolState());
+ if (riskWindowStart != 0) {
+ emit HighRiskStake(msg.sender, amount, riskWindowStart);
+ }
// ... rest of stake ...
}

This preserves the intentional design (UNDER_ATTACK deposits remain possible), adds negligible gas overhead, and enables frontends to display a clear warning: "This pool is in an active-risk state. Your deposit will be locked until resolution, earns negligible bonus, and may be forfeited if the agreement is breached."

Support

FAQs

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

Give us feedback!