Conditional ETH Wrapping Logic Causes Withdrawal DoS in MoonwellWETH and StargateETH Strategies
Summary
Both MoonwellWETHStrategy.sol and StargateEthPoolStrategy.sol on Optimism contain flawed conditional logic in their _deallocate() functions that prevents ETH from being wrapped to WETH when withdrawals experience any slippage or loss. This causes the functions to revert, blocking users from withdrawing funds during normal market conditions.
Both strategies implement the same problematic pattern. Let me walk through MoonwellWETH as the example (Stargate has identical logic):
Lines 54-70 from MoonwellWETHStrategy.sol:
The Issue is on Line 64:
This conditional check only wraps the ETH if the total amount (redeemed + any pre-existing balance) is sufficient. In normal operation, strategies don't accumulate ETH balances - they start with 0 ETH.
Execution Flow When Loss Occurs:
Scenario: User requests to deallocate 100 WETH, but receives 98 ETH due to 2% slippage.
Result: Transaction fails with 98 ETH stuck unwrapped in the strategy contract.
Comment vs Code Contradiction
Look at line 58 comment in MoonwellWETH:
The comment explicitly states "wrap any ETH received", but the code implements conditional wrapping that only wraps if there's no loss. This is a clear contradiction between intent and implementation.
Similarly, in StargateEthPoolStrategy line 65:
It says "then wrap back to WETH" - not "wrap back if we have enough". The comment implies unconditional wrapping.
Why Strategies Don't Have Leftover ETH
I verified that in normal operation, strategies should not accumulate ETH:
_allocate() doesn't leave ETH: Takes WETH as input, converts if needed, deposits to protocol
_deallocate() should wrap immediately: Receives ETH from protocol, supposed to wrap to WETH
receive() exists only for protocol redemptions: Not for general ETH deposits
The only source of ETH is from the redemption itself. The conditional assumes there might be leftover ETH from previous operations, but this shouldn't happen in normal flow.
Comparison with Other Strategies
I checked all other WETH strategies in the codebase and none use this conditional pattern:
TokeAutoEth (mainnet) - Lines 77-82:
No conditional wrapping - just measures, emits loss event if needed, then continues to require check.
function _deallocate(uint256 amount) internal override returns (uint256) {
uint256 ethBalanceBefore = address(this).balance;
// Pull exact amount of underlying WETH out
mWETH.redeemUnderlying(amount);
// wrap any ETH received (Moonwell redeems to ETH for WETH markets)
uint256 ethBalanceAfter = address(this).balance;
uint256 ethRedeemed = ethBalanceAfter - ethBalanceBefore;
if (ethRedeemed < amount) {
emit StrategyDeallocationLoss("Strategy deallocation loss.", amount, ethRedeemed);
}
if (ethRedeemed + ethBalanceBefore >= amount) {
weth.deposit{value: ethRedeemed}();
}
require(TokenUtils.safeBalanceOf(address(weth), address(this)) >= amount, "Strategy balance is less than the amount needed");
TokenUtils.safeApprove(address(weth), msg.sender, amount);
return amount;
}
if (ethRedeemed + ethBalanceBefore >= amount) {
weth.deposit{value: ethRedeemed}();
}
Line 55: ethBalanceBefore = 0 (strategy has no leftover ETH)
Line 57: mWETH.redeemUnderlying(100 WETH)
→ Moonwell sends 98 ETH to strategy
→ receive() function catches this ETH (line 108)
Line 59: ethBalanceAfter = 98 ETH
Line 60: ethRedeemed = 98 - 0 = 98 ETH
Line 61-63: if (98 < 100) → TRUE → emits StrategyDeallocationLoss event ✓
Line 64: if (98 + 0 >= 100) → if (98 >= 100) → FALSE ✗
→ weth.deposit() is NOT called
→ 98 ETH remains unwrapped in the contract
Line 67: require(wethBalance >= 100)
→ require(0 >= 100) → REVERTS ✗
// wrap any ETH received (Moonwell redeems to ETH for WETH markets)
// Redeem LP to native ETH, then wrap back to WETH