57604 sc high nominal accounting mismatch in moonwell strategies leads to permanent locking of all generated yield

Submitted on Oct 27th 2025 at 14:35:40 UTC by @sus_bandicoot for Audit Comp | Alchemix V3arrow-up-right

  • Report ID: #57604

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/strategies/optimism/MoonwellWETHStrategy.sol

  • Impacts:

    • Permanent freezing of unclaimed yield

Description

Brief/Intro

The MoonwellWETHStrategy and MoonwellUSDCStrategy contracts deposit user assets into Moonwell's yield-bearing mTokens. However, the Alchemix V3 system (via the Morpho VaultV2) tracks these deposits using a fixed, nominal asset amount. When deallocating, the strategies are only able to redeem the original nominal amount of assets, not the full value of their appreciated mToken shares. This accounting mismatch means all generated yield becomes permanently trapped in the strategy contracts, resulting in a total loss of earnings for the users who deposited funds into the vault.

Vulnerability Details

The protocol's yield-generation process involves three main components:

  1. AlchemistAllocator: The contract used by operators to allocate funds.

  2. VaultV2 (Morpho): The vault that holds the assets and tracks nominal allocations per strategy.

  3. Strategy Adapters (e.g., MoonwellWETHStrategy): The contracts that interact with the external yield protocols.

Allocation Flow:

  1. An operator calls AlchemistAllocator.allocate(...) to send, for example, 100 WETH to the MoonwellWETHStrategy.

  2. The AlchemistAllocator calls VaultV2.allocate(...).

  3. The VaultV2 contract transfers 100 WETH (which users previously deposited into the vault) to the MoonwellWETHStrategy. It then calls MoonwellWETHStrategy.allocate(...) and internally records the nominal allocation = 100 WETH for this strategy.

The VaultV2 then calls the allocate(...) function on the MoonwellWETHStrategy, which implements the logic to mint mTokens:

The strategy now holds 100 mWETH (assuming a 1:1 minting ratio for simplicity).

Deallocation Flow: Over time, the 100 mWETH held by the strategy appreciate in value due to accruing yield. Let's say the exchange rate doubles (a 1:2 ratio), and those 100 mWETH are now worth 200 WETH.

  1. The VaultV2's recorded allocation for the strategy is still the nominal 100 WETH (set during the allocation flow).

  2. An operator wishing to withdraw must call AlchemistAllocator.deallocate(...). The problem is that they can only deallocate up to the nominal amount, 100 WETH. Attempting to deallocate anything above the nominal 100 WETH, e.g. the full 200 WETH (the strategy's real value) would fail, as the system's accounting logic would underflow. The following steps demonstrate this process.

  3. The operator calls AlchemistAllocator.deallocate(adapter, 100 WETH). This function first fetches the current nominal allocation from the vault and packs it as bytes memory oldAllocation.

  1. The AlchemistAllocator then calls vault.deallocate(adapter, oldAllocation, 100 WETH), where oldAllocation is the encoded 100 WETH and amount (the assets parameter) is also 100 WETH.

The VaultV2 receives this call, calls the adapter's deallocate function, and prepares to update its internal nominal allocation based on the change returned.

The vault's call is received by the MYTStrategy.deallocate(...) function, which decodes the oldAllocation (from data) and calls the internal _deallocate(...) function.

This is the place that would cause the transaction to revert if the operator attempted to withdraw more than the initial nominal allocation of assets. The underflow in the subtraction blocks the deallocation of the strategy earnings.

Finally, the MoonwellWETHStrategy._deallocate(...) function executes the redemption logic. As can be seen, the return value of a call to _deallocate(...) in the snippet above, is simply the input argument to the function (shown below).

The call to mWETH.redeemUnderlying(100 WETH) successfully redeems 100 WETH by burning only a portion of the strategy's mWETH (e.g., 50 mWETH, given our 1:2 ratio). The 100 WETH are returned to the VaultV2.

The VaultV2's allocation for this strategy is now updated to 0 (100 + (-100)).

The remaining 50 mWETH (worth the other 100 WETH), which represent the entire yield, are now permanently locked in the MoonwellWETHStrategy contract. Since the VaultV2's allocation is 0, no further deallocate calls can be made to retrieve them. There is no other mechanism to withdraw these remaining shares.

Impact Details

The primary and direct impact is the permanent and total loss of all yield generated by the MoonwellWETHStrategy and MoonwellUSDCStrategy.

While users can recover their principal (the initial nominal deposit)*, the entire purpose of these strategies is defeated, as all profits are irrecoverably locked. This renders these two strategies unprofitable by design, causing a direct loss of all earned yield for the vault's depositors. This guaranteed outcome aligns with a High-severity rating.

*A secondary impact stems from the realAssets() view function, which will report a misleadingly high value.

This function calculates the value of all mTokens held, including the trapped, irrecoverable yield. This inflates the perceived value of the strategy and the vault's overall share price, misleading operators and users about the system's true performance.

Furthermore, this inflated share price creates the potential for a more severe, Critical-severity scenario. The first users to redeem their shares would be paid out at an artificially high price, drawing from the vault's actual recoverable principal. This comes at the direct expense of the last depositors, who would be left holding worthless shares backed only by the trapped yield.

This report focuses on the guaranteed High-severity impact (the permanent loss of yield), as the Critical-severity bank run scenario is conditional on a specific mass-exit event and is of lower likelihood.

Since MoonwellWETHStrategy and MoonwellUSDCStrategy are identical except for the underlying asset, the same issue is present in both contracts.

References

  • AlchemistAllocator: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistAllocator.sol

  • MYTStrategy: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/MYTStrategy.sol

  • MoonwellUSDCStrategy: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/strategies/optimism/MoonwellUSDCStrategy.sol

  • MoonwellWETHStrategy: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/strategies/optimism/MoonwellWETHStrategy.sol

  • VaultV2 (Morpho): https://github.com/morpho-org/vault-v2/blob/main/src/VaultV2.sol

  • mWETH: https://optimistic.etherscan.io/address/0xb4104C02BBf4E9be85AAa41a62974E4e28D59A33#code

Proof of Concept

Proof of Concept

The test output demonstrates the bug. The logs show that after allocating 1000 WETH and letting yield accrue (20 days), the strategy's underlying value increased to 1000.32 WETH.

After the operator deallocates the original 1000 WETH, the vault's internal allocation for the strategy drops to 0. The remaining 0.32 WETH (the yield) is still in the strategy, as shown by the "Remaining trapped yield" log.

The final step proves these funds are locked. The attempt to withdraw this remaining yield reverts, as the VaultV2 contract (and MYTStrategy) no longer permits deallocation from a strategy it considers to have a zero balance.

PoC Instructions:

  1. The test contract can be found below. Copy it and paste it into: src/test/strategies/MoonwellWETHStrategy.t.sol

  2. Run the test with the following command:

Logs:

Test file: src/test/strategies/MoonwellWETHStrategy.t.sol

Was this helpful?