The MoonwellWETHStrategy and MoonwellUSDCStrategy contracts on Optimism fail to check return codes from Moonwell's mint() and redeemUnderlying() functions, which follow Compound's convention of returning 0 on success and non-zero error codes on failure without reverting. When Moonwell rejects operations (due to market pause, borrow cap, or comptroller policy), the strategies silently continue execution, reporting successful allocations to the vault while leaving underlying assets idle in the strategy contract. This creates an accounting discrepancy where the vault believes funds are allocated and earning yield, but realAssets() reports zero position, violating protocol invariants and preventing proper capital deployment.
Vulnerability Details
Moonwell inherits Compound v2's error handling system where mint() and redeemUnderlying() return uint error codes instead of reverting. According to Compound documentation and Moonwell's own audit findings, these functions return 0 for success and non-zero values (1-14) for various error conditions such as market paused (13), insufficient liquidity, comptroller rejection, or borrow cap reached.
Vulnerable Code in _allocate()
MoonwellWETHStrategy.sol:
MoonwellUSDCStrategy.sol:
Vulnerable Code in _deallocate()
MoonwellWETHStrategy.sol:
MoonwellUSDCStrategy.sol:
Failure Scenario
When Moonwell's market conditions prevent minting:
Operator calls strategy.allocate(10 ETH)
Strategy approves WETH to mWETH contract
mWETH.mint(10 ETH) returns error code 13 (market paused) - does not revert
Strategy ignores return value and continues
Function returns amount = 10 ETH to vault
Vault records successful allocation of 10 ETH
realAssets() returns 0 (no mTokens minted)
10 WETH sits idle in strategy earning no yield
Impact Details
Allocations can falsely succeed while assets remain idle in the strategy, causing temporary withdrawal/rebalance failures and degraded liveness until retry or remediation.
Users may experience failed exits and zero yield on “allocated” funds during Moonwell pauses or caps, with no principal loss but clear service disruption.
function _allocate(uint256 amount) internal override returns (uint256) {
require(TokenUtils.safeBalanceOf(address(weth), address(this)) >= amount,
"Strategy balance is less than amount");
TokenUtils.safeApprove(address(weth), address(mWETH), amount);
mWETH.mint(amount); // ❌ Return value ignored - should check == 0
return amount; // Claims success regardless of actual mint result
}
function _allocate(uint256 amount) internal override returns (uint256) {
require(TokenUtils.safeBalanceOf(address(usdc), address(this)) >= amount,
"Strategy balance is less than amount");
TokenUtils.safeApprove(address(usdc), address(mUSDC), amount);
mUSDC.mint(amount); // ❌ Return value ignored - should check == 0
return amount; // Claims success regardless of actual mint result
}
function _deallocate(uint256 amount) internal override returns (uint256) {
uint256 ethBalanceBefore = address(this).balance;
mWETH.redeemUnderlying(amount); // ❌ Return value ignored
uint256 ethBalanceAfter = address(this).balance;
uint256 ethRedeemed = ethBalanceAfter - ethBalanceBefore;
if (ethRedeemed < amount) {
emit StrategyDeallocationLoss(...);
}
// Later require catches failure but with generic error message
}
function _deallocate(uint256 amount) internal override returns (uint256) {
uint256 usdcBalanceBefore = TokenUtils.safeBalanceOf(address(usdc), address(this));
mUSDC.redeemUnderlying(amount); // ❌ Return value ignored
uint256 usdcBalanceAfter = TokenUtils.safeBalanceOf(address(usdc), address(this));
uint256 usdcRedeemed = usdcBalanceAfter - usdcBalanceBefore;
if (usdcRedeemed < amount) {
emit StrategyDeallocationLoss(...);
}
// Later require catches failure but obscures root cause
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "../libraries/BaseStrategyTest.sol";
import {MoonwellWETHStrategy} from "../../strategies/optimism/MoonwellWETHStrategy.sol";
import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol";
/* ============ Minimal Moonwell mToken mock ============ */
interface IMTokenLike {
function mint(uint256 mintAmount) external returns (uint256);
function redeemUnderlying(uint256 redeemAmount) external returns (uint256);
function balanceOfUnderlying(address owner) external returns (uint256);
function balanceOf(address owner) external view returns (uint256);
function exchangeRateStored() external view returns (uint256);
function exchangeRateCurrent() external returns (uint256);
}
contract MockMWETH is IMTokenLike {
bool public failMint;
bool public failRedeem;
uint256 public exchangeRate = 1e18; // 1:1
mapping(address => uint256) public mBalance;
function setFailMint(bool v) external { failMint = v; }
function setFailRedeem(bool v) external { failRedeem = v; }
function setExchangeRate(uint256 r) external { exchangeRate = r; }
// Compound-style return code: 0 success, !=0 failure
function mint(uint256 mintAmount) external returns (uint256) {
if (failMint) return 1;
uint256 shares = (mintAmount * 1e18) / exchangeRate;
mBalance[msg.sender] += shares;
return 0;
}
function redeemUnderlying(uint256 redeemAmount) external returns (uint256) {
if (failRedeem) return 2;
uint256 shares = (redeemAmount * 1e18) / exchangeRate;
require(mBalance[msg.sender] >= shares, "insufficient shares");
mBalance[msg.sender] -= shares;
return 0;
}
function balanceOfUnderlying(address owner) external returns (uint256) {
return (mBalance[owner] * exchangeRate) / 1e18;
}
function balanceOf(address owner) external view returns (uint256) {
return mBalance[owner];
}
function exchangeRateStored() external view returns (uint256) {
return exchangeRate;
}
function exchangeRateCurrent() external returns (uint256) {
return exchangeRate;
}
}
/* ============ Strategy wrapper ============ */
contract MockMoonwellWETHStrategy is MoonwellWETHStrategy {
constructor(
address _myt,
StrategyParams memory _params,
address _mWETH,
address _weth,
address _permit2Address
) MoonwellWETHStrategy(_myt, _params, _mWETH, _weth, _permit2Address) {}
}
/* ============ Test suite ============ */
contract MoonwellWETHStrategyTest is BaseStrategyTest {
MockMWETH internal mockMWeth;
address public constant WETH_OP = 0x4200000000000000000000000000000000000006; // real OP WETH
address public constant OPTIMISM_PERMIT2 = 0x000000000022d473030f1dF7Fa9381e04776c7c5;
function getRpcUrl() internal view override returns (string memory) {
return vm.envString("OPTIMISM_RPC_URL");
}
function getForkBlockNumber() internal pure override returns (uint256) {
return 141_751_698;
}
function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) {
return IMYTStrategy.StrategyParams({
owner: address(1),
name: "MoonwellWETH",
protocol: "MoonwellWETH",
riskClass: IMYTStrategy.RiskClass.LOW,
cap: 10_000e18,
globalCap: 1e18,
estimatedYield: 100e18,
additionalIncentives: false,
slippageBPS: 1
});
}
function getTestConfig() internal pure override returns (TestConfig memory) {
return TestConfig({
vaultAsset: WETH_OP, // use live OP WETH for vault
vaultInitialDeposit: 1000e18,
absoluteCap: 10_000e18,
relativeCap: 1e18,
decimals: 18
});
}
function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) {
mockMWeth = new MockMWETH();
return address(new MockMoonwellWETHStrategy(vault, params, address(mockMWeth), WETH_OP, OPTIMISM_PERMIT2));
}
// BUG PoC: current strategy does NOT revert when Moonwell mint returns nonzero,
// reporting positive allocation change while realAssets() remains zero.
function test_allocate_does_not_revert_on_moonwell_mint_failure_bug() public {
vm.startPrank(vault);
mockMWeth.setFailMint(true); // force mint failure (code != 0)
uint256 amount = 10 ether;
// Credit the strategy with live WETH via Foundry deal
deal(WETH_OP, address(strategy), amount);
bytes memory prev = abi.encode(0);
(bytes32[] memory sids, int256 change) = IMYTStrategy(strategy).allocate(prev, amount, "", address(vault));
// Bug: no revert and positive change is reported
assertGt(sids.length, 0, "no strategy id");
assertEq(change, int256(amount), "unexpected change reported");
// But no mTokens were minted; exposure stays zero
uint256 ra = IMYTStrategy(strategy).realAssets();
assertEq(ra, 0, "realAssets should remain zero when mint fails");
vm.stopPrank();
}
}