Skip to content

MorphoERC4626SwapConnectors: gate redeem quotes on servicable liquidity - #175

Merged
Kay-Zee merged 4 commits into
mainfrom
fix/morpho-4626-liquidity-aware-quotes
Aug 31, 2026
Merged

MorphoERC4626SwapConnectors: gate redeem quotes on servicable liquidity#175
Kay-Zee merged 4 commits into
mainfrom
fix/morpho-4626-liquidity-aware-quotes

Conversation

@Kay-Zee

@Kay-Zee Kay-Zee commented Aug 31, 2026

Copy link
Copy Markdown
Member

Problem

MorphoERC4626SwapConnectors.Swapper quotes the shares→assets direction with previewRedeem/previewWithdraw, which are pure NAV math and ignore the vault's available liquidity. When this Swapper is composed in a SwapConnectors.MultiSwapper alongside 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 inside redeem and the whole transaction panics; there is no mid-execution fallback in Cadence.

Observed on mainnet: tx 790033c1b12d31e1f9a77bbf40091ae16773779536daa6d85585fa20c0342972 — a yield vault withdrawal reverted with Failed to redeem 9431.20746239 shares … execution reverted on the Morpho ERC4626 vault 0xd069d989e2f44b70c65347d1853c0c67e10a9f8d (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 VaultV2Lens is deployed on mainnet and reports maxWithdraw(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 field from the update validator), and a new Swapper.init parameter would break every existing caller (e.g. FlowYieldVaults' strategy composers).
  • Setting/clearing the address is a single transaction signed by the contract account (cadence/transactions/evm/morpho/set_vault_v2_lens.cdc) — no contract update, no downstream changes.
  • When configured, the lens result gates the quote (assets <= lens.maxWithdraw(vault)). If unset, or if the lens call fails (e.g. the vault is not a VaultV2 and lacks liquidityAdapter()), the adapter/idle heuristic below applies unchanged.

Why not the vault's own maxRedeem/maxWithdraw

The obvious ERC4626 liquidity views don't work here. The incident vault is a Morpho Vault V2 (adaptersLength() = 2), and Vault V2 hardcodes both to return 0 ("gross underestimation because being revert-free cannot be guaranteed when calling the gate" — VaultV2.sol). Verified on-chain: maxRedeem returns 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 configured liquidityAdapter when 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 ERC4626Utils views:

  • 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:

Vault state Behavior
liquidityAdapter() reverts/absent Legacy ungated quoting (no regression for V1/plain vaults)
Adapter set (nonzero) Legacy ungated quoting (adapter liquidity not measured generically — see limits)
Adapter provably unset Quote 0.0 unless assets <= idleAssets

With 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 MultiSwapper already routes around, so an illiquid vault automatically falls back to the AMM leg, and downstream minimumAvailable() stops advertising unservable exits.

Known limits

  • Without a configured lens, adapter-backed Vault V2 instances keep legacy behavior (configuring the lens is the remedy). A testnet VaultV2Lens deployment does not exist yet and would be needed before configuring the lens on testnet.
  • The gate is all-or-nothing per quote, matching MultiSwapper's single-leg selection — partial idle liquidity routes the full exit to the AMM rather than splitting across legs.
  • Deposit-direction quotes are untouched; gating on deposit caps is a separate concern.
  • Quotes can go stale between estimate and execution (same race as AMM quotes); the gate removes the deterministic failure, not the race.

Testing

flow test cadence/tests/MorphoERC4626SwapConnectors_test.cdc passes under both flow-cli v2.17.4 and v2.15.3 (the version CI pins). Full cadence/tests suite under v2.15.3 (CI's exact make test command): 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 (Serialize et al.) have since adopted StringBuilder, 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 and maxRedeem a 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. testSwap no longer hardcodes the swapBack amount; it swaps back the actual received share balance, resolving the previous @TODO investigage losses magic number (the losses are previewDeposit floor-rounding plus Cadence↔EVM decimal truncation — and pool fees whenever the AMM leg serves the route).
  • New 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.
  • New 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's mainnet-fork-morpho-erc4626-connectors entry) and verifies the config write/read/clear cycle. Negative control: configuring the vault itself as the lens zeroes the quote — VaultV2's maxWithdraw is 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.

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
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.
@Kay-Zee
Kay-Zee merged commit b8d5cb6 into main Aug 31, 2026
3 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants