MorphoERC4626SwapConnectors: gate redeem quotes on servicable liquidity - #175
Merged
Conversation
previewRedeem/previewWithdraw quote full NAV regardless of the vault's available liquidity, so the redeem leg always won MultiSwapper quote comparisons and then reverted at execution when the vault could not pay out, instead of routing to an AMM leg. Morpho Vault V2 hardcodes maxRedeem/maxWithdraw to 0, so servicability is derived from VaultV2.exit semantics instead: when the vault provably has no liquidity adapter (liquidityAdapter() == 0), redemptions are served from idle assets only and quotes return 0.0 - the graceful failure MultiSwapper routes around - unless the redemption fits in the vault's idle balance. Vaults without liquidityAdapter() (MetaMorpho V1, plain ERC4626) or with an adapter set keep legacy ungated behavior.
A configured VaultV2Lens reports maxWithdraw = idle assets + liquidity adapter availability for any Vault V2 vault, covering adapter-backed vaults that the liquidityAdapter/idle heuristic cannot measure. The lens address lives in the contract account's storage (not contract state) because in-place contract updates reject new contract fields and a new Swapper.init parameter would break existing callers. Set or clear it with transactions/evm/morpho/set_vault_v2_lens.cdc signed by the contract account; unset or failing lens calls fall back to the heuristic. Off by default. Fork test height bumped to 163054070 (the lens postdates the previously pinned height); the new lens test uses the fork's local key for the contract account to write config and includes a negative control (vault-as-lens, whose hardcoded maxWithdraw = 0 zeroes the quote).
Kay-Zee
marked this pull request as ready for review
August 31, 2026 21:54
CI pins flow-cli v2.15.3, whose Cadence cannot type-check current mainnet bridge contracts (Serialize uses StringBuilder), so any fork height past that deployment fails CI at dependency load. Revert to the previously pinned height and verify the lens branch with the height-independent negative control (vault-as-lens: maxWithdraw is hardcoded to 0). The real-lens integration is verified against live mainnet state; verified locally with both v2.15.3 and v2.17.4.
Depositing 1.0 PYUSD0 yields a height-dependent share amount (exchange rate, previewDeposit floor-rounding, and Cadence<->EVM decimal truncation), so the hardcoded 0.99 swapBack and its 'investigate losses' TODO are replaced by reading the received share balance and swapping it back in full.
Gornutz
approved these changes
Aug 31, 2026
This was referenced Aug 31, 2026
Kay-Zee
added a commit
that referenced
this pull request
Sep 1, 2026
…ty (#175) (#177) * gate Morpho ERC4626 redeem quotes on servicable liquidity previewRedeem/previewWithdraw quote full NAV regardless of the vault's available liquidity, so the redeem leg always won MultiSwapper quote comparisons and then reverted at execution when the vault could not pay out, instead of routing to an AMM leg. Morpho Vault V2 hardcodes maxRedeem/maxWithdraw to 0, so servicability is derived from VaultV2.exit semantics instead: when the vault provably has no liquidity adapter (liquidityAdapter() == 0), redemptions are served from idle assets only and quotes return 0.0 - the graceful failure MultiSwapper routes around - unless the redemption fits in the vault's idle balance. Vaults without liquidityAdapter() (MetaMorpho V1, plain ERC4626) or with an adapter set keep legacy ungated behavior. * support VaultV2Lens as authoritative liquidity oracle, non-breaking A configured VaultV2Lens reports maxWithdraw = idle assets + liquidity adapter availability for any Vault V2 vault, covering adapter-backed vaults that the liquidityAdapter/idle heuristic cannot measure. The lens address lives in the contract account's storage (not contract state) because in-place contract updates reject new contract fields and a new Swapper.init parameter would break existing callers. Set or clear it with transactions/evm/morpho/set_vault_v2_lens.cdc signed by the contract account; unset or failing lens calls fall back to the heuristic. Off by default. Fork test height bumped to 163054070 (the lens postdates the previously pinned height); the new lens test uses the fork's local key for the contract account to write config and includes a negative control (vault-as-lens, whose hardcoded maxWithdraw = 0 zeroes the quote). * keep fork height CI-compatible; make lens test height-independent CI pins flow-cli v2.15.3, whose Cadence cannot type-check current mainnet bridge contracts (Serialize uses StringBuilder), so any fork height past that deployment fails CI at dependency load. Revert to the previously pinned height and verify the lens branch with the height-independent negative control (vault-as-lens: maxWithdraw is hardcoded to 0). The real-lens integration is verified against live mainnet state; verified locally with both v2.15.3 and v2.17.4. * swap back actual share balance in testSwap instead of magic 0.99 Depositing 1.0 PYUSD0 yields a height-dependent share amount (exchange rate, previewDeposit floor-rounding, and Cadence<->EVM decimal truncation), so the hardcoded 0.99 swapBack and its 'investigate losses' TODO are replaced by reading the received share balance and swapping it back in full.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
MorphoERC4626SwapConnectors.Swapperquotes the shares→assets direction withpreviewRedeem/previewWithdraw, which are pure NAV math and ignore the vault's available liquidity. When this Swapper is composed in aSwapConnectors.MultiSwapperalongside an AMM leg (as FlowYieldVaults' FUSDEV strategy does), the redeem leg always wins the quote comparison — no slippage vs AMM slippage — even when the vault cannot pay out. Execution then reverts insideredeemand the whole transaction panics; there is no mid-execution fallback in Cadence.Observed on mainnet: tx
790033c1b12d31e1f9a77bbf40091ae16773779536daa6d85585fa20c0342972— a yield vault withdrawal reverted withFailed to redeem 9431.20746239 shares … execution revertedon the Morpho ERC4626 vault0xd069d989e2f44b70c65347d1853c0c67e10a9f8d(euSDEV, underlying PYUSD0) while the AMM exit path had liquidity and was never selected.A side effect:
Source.minimumAvailable()downstream of this Swapper reports full NAV as withdrawable even when nothing can be redeemed, so UIs advertise exits that must revert.VaultV2Lens support (non-breaking, off by default)
A
VaultV2Lensis deployed on mainnet and reportsmaxWithdraw(vault)= idle + liquidity available through the vault's liquidity adapter (handling known adapter types) — the authoritative version of this PR's heuristic, including for adapter-backed vaults. This PR wires it in without breaking any caller:MorphoERC4626SwapConnectors.getVaultV2Lens()reads an optional lens address from the contract account's storage (/storage/MorphoERC4626VaultV2Lens). Contract-account storage is used because in-place contract updates reject new contract fields (verified:error: found new fieldfrom the update validator), and a newSwapper.initparameter would break every existing caller (e.g. FlowYieldVaults' strategy composers).cadence/transactions/evm/morpho/set_vault_v2_lens.cdc) — no contract update, no downstream changes.assets <= lens.maxWithdraw(vault)). If unset, or if the lens call fails (e.g. the vault is not a VaultV2 and lacksliquidityAdapter()), the adapter/idle heuristic below applies unchanged.Why not the vault's own
maxRedeem/maxWithdrawThe obvious ERC4626 liquidity views don't work here. The incident vault is a Morpho Vault V2 (
adaptersLength() = 2), and Vault V2 hardcodes both toreturn 0("gross underestimation because being revert-free cannot be guaranteed when calling the gate" —VaultV2.sol). Verified on-chain:maxRedeemreturns 0 for holders with nonzero balances.What actually bounds a Vault V2 redemption (
VaultV2.exit): the vault's idle asset balance, topped up by a configuredliquidityAdapterwhen idle is insufficient. The incident vault has no liquidity adapter set (liquidityAdapter() == address(0)), so its servicable liquidity is exactly its idle balance — which was below the requested redemption at the time of the incident transaction.Fix
Gate both shares→assets quote functions on servicable liquidity, via two new
ERC4626Utilsviews:idleAssets(vault)— the vault's own balance of the underlying asset.liquidityAdapter(vault)— the configured adapter; distinguishes zero address (provably no adapter) from nil (vault doesn't expose the function, e.g. MetaMorpho V1 or plain ERC4626).Routing behavior in
canServiceRedemption:liquidityAdapter()reverts/absentassets <= idleAssetsWith a lens configured, the lens check runs first and covers all Vault V2 cases, including adapter-backed ones.
A 0.0 quote is the graceful-failure convention
MultiSwapperalready routes around, so an illiquid vault automatically falls back to the AMM leg, and downstreamminimumAvailable()stops advertising unservable exits.Known limits
MultiSwapper's single-leg selection — partial idle liquidity routes the full exit to the AMM rather than splitting across legs.Testing
flow test cadence/tests/MorphoERC4626SwapConnectors_test.cdcpasses under both flow-cli v2.17.4 and v2.15.3 (the version CI pins). Fullcadence/testssuite under v2.15.3 (CI's exactmake testcommand): 72 passing, 0 failing.Note on fork heights: the VaultV2Lens was deployed after this file's pinned fork height (142104481), and the fork height cannot simply be bumped past it — mainnet's bridge contracts (
Serializeet al.) have since adoptedStringBuilder, which the Cadence bundled in CI's flow-cli v2.15.3 cannot type-check, so any recent-height fork test fails CI at dependency load. Bumping CI's flow-cli pin is a worthwhile follow-up; out of scope here.The real-lens integration is verified against live mainnet instead of the fork:
VaultV2Lens.maxWithdraw(0xd069d989e2f44b70c65347d1853c0c67e10a9f8d)returned 4,778.31 PYUSD0 andmaxRedeema matching 4,524.35 shares (≈4,778.30 PYUSD0 at the vault's exchange rate), consistent with the vault's idle balance (no liquidity adapter is set on this vault, so the lens reduces to idle by construction).testQuoteIn,testSwap(real deposit → redeem roundtrip) — pass: the gate is transparent when idle covers the redemption.testSwapno longer hardcodes the swapBack amount; it swaps back the actual received share balance, resolving the previous@TODO investigage lossesmagic number (the losses arepreviewDepositfloor-rounding plus Cadence↔EVM decimal truncation — and pool fees whenever the AMM leg serves the route).testQuoteOutSharesToAssetsGatedByRedeemableLiquidity— a servicable amount (1.0 share) quotes non-zero; an amount orders of magnitude beyond total share supply quotes 0.0/0.0 instead of unpayable NAV. Height-agnostic by construction.testQuoteOutGatedByConfiguredVaultV2Lens— writes lens config to the contract account's storage on the fork (the fork wires a local key for the contract account via flow.json'smainnet-fork-morpho-erc4626-connectorsentry) and verifies the config write/read/clear cycle. Negative control: configuring the vault itself as the lens zeroes the quote — VaultV2'smaxWithdrawis hardcoded to 0, so the quote can only go to 0.0 if the configured-lens branch executed (the heuristic path quotes non-zero at the pinned height). Passes under both flow-cli versions above.