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

### [L-01] `UNDER_ATTACK` deposits are not "crushed to ~zero": a first-observer earns full early-entrant bonus weight, diluting honest long-term stakers

Author Revealed upon completion

Description

_assertDepositsAllowed intentionally allows deposits during UNDER_ATTACK while blocking PROMOTION_REQUESTED/PRODUCTION/CORRUPTED:

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
@> || state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

DESIGN.md §3 justifies this as safe because such a deposit's "bonus share is crushed to ~zero by the k=2 weighting — entry is floored at riskWindowStart ≈ now, so (T − entry)² ≈ 0 ... There is no late-join advantage to gate against."

That claim does not hold for the first account to interact once the registry is UNDER_ATTACK. T in the k=2 score is riskWindowEnd, and a first-observer's stake() itself seals riskWindowStart via _observePoolState(), then floors the caller's own entry to it:

_assertDepositsAllowed(_observePoolState()); // seals riskWindowStart = now on first UNDER_ATTACK obs
...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
@> if (start != 0 && newEntry < start) newEntry = start; // caller's entry == riskWindowStart

So the first UNDER_ATTACK depositor's weight is (riskWindowEnd − riskWindowStart)² — the maximum early-entrant weight, identical to a staker who committed blind for the entire pre-risk term (who also floors to riskWindowStart per §7). Per DESIGN.md §1, UNDER_ATTACK is the normal, long-lived "legally attackable" operating mode that usually resolves to SURVIVED, so an opportunist who deposits only once UNDER_ATTACK is visible on-chain (with far better survival information, and locking capital for only the short observed-risk window rather than the full blind term) captures a full early-entrant bonus share and dilutes honest long-term stakers toward parity.

DESIGN.md §7 in fact endorses the actual behavior ("every deposit made before the seal, including the one that triggers it, floors to riskWindowStart"), so §3's stated safety invariant contradicts both §7 and the code. This is reported because §3's specific justification for allowing UNDER_ATTACK deposits is demonstrably false — the "no late-join advantage" it relies on does not exist for the first observer.

Risk

Likelihood:

  • The opportunist must be the first account to interact with the pool after the registry enters UNDER_ATTACK (any earlier honest stake or pokeRiskWindow seals riskWindowStart first, after which a later deposit floors to a real, later entry and is correctly crushed). Reachable whenever a pool has parked bonus liquidity but no active pre-seal interaction.

Impact:

  • Bonus-pool redistribution: an informed late entrant captures the same time-weight as blind long-term stakers, diluting their bonus share. No principal loss (the late stake is genuinely locked and forfeit on CORRUPTED), so this is a fairness/economic-incentive issue, not theft.

Proof of Concept

test/audit/DesignDivergence.t.sol (test_underAttackDepositor_earns_full_not_zero_bonus) — passes under forge test.

function test_underAttackDepositor_earns_full_not_zero_bonus() public {
_contributeBonus(makeAddr("sponsor"), 100 ether);
_stake(alice, 100 ether); // honest long-term staker, commits blind
vm.warp(block.timestamp + 20 days); // 20 days pre-risk commitment
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_stake(bob, 100 ether); // JIT: first observer -> his stake seals riskWindowStart
assertTrue(pool.riskWindowStart() != 0);
vm.warp(block.timestamp + 3 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// ... both claim ...
// DESIGN §3 claims bob is "crushed to ~zero"; in reality bobBonus == aliceBonus (~50 ether each).
assertEq(bobBonus, aliceBonus);
assertGt(bobBonus, 49 ether);
}

Result: PASS — bob (the JIT UNDER_ATTACK depositor) earns 50 ether bonus, exactly equal to the 20-day committed staker, not "~zero."

Recommended Mitigation

Option (a) — if UNDER_ATTACK deposits carry no intended late-join advantage (per §3), close them like PROMOTION_REQUESTED, removing the first-observer edge entirely:

function _assertDepositsAllowed(IAttackRegistry.ContractState state) private pure {
if (
- state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
+ state == IAttackRegistry.ContractState.UNDER_ATTACK
+ || state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION || state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
}

Note the first-observer edge cannot be fixed by changing the entry-floor at the stake site: a first-observer's own stake() seals riskWindowStart to their block.timestamp in _observePoolState() before the floor line runs, so newEntry < start is already false for them — their entry equals riskWindowStart because the seal is their timestamp, not because of the floor. Any fix that preserves UNDER_ATTACK deposits must therefore decouple "who seals the window" from "who gets credited from the seal" — e.g. require the risk window to already be sealed (by a prior interaction or pokeRiskWindow) before an UNDER_ATTACK deposit is accepted, so the entrant's real (later) block.timestamp is used and correctly crushed. Blocking the state (option a) is the simpler removal.

At minimum, correct DESIGN.md §3 to match the §7/code behavior so the "no late-join advantage" safety claim is not relied upon by future integrators or auditors.

Support

FAQs

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

Give us feedback!