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

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."

Updates

Lead Judging Commences

inallhonesty Lead Judge
8 days ago
inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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

Give us feedback!