diff --git a/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc b/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc index 40cf8417..da1d2872 100644 --- a/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc +++ b/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc @@ -23,6 +23,18 @@ import "EVMAmountUtils" /// access(all) contract MorphoERC4626SwapConnectors { + /// Returns the VaultV2Lens EVM address configured for this contract, or nil if none is set. + /// + /// The lens address is read from this contract account's storage rather than contract state so it can be + /// set or replaced without a contract update: in-place contract updates reject new contract fields, and a + /// new init parameter would break every caller of Swapper.init. Set or clear it with a transaction signed + /// by this contract's account (see transactions/evm/morpho/set_vault_v2_lens.cdc). + /// + /// @return The configured VaultV2Lens EVM address, or nil if unset or the stored value has the wrong type + access(all) view fun getVaultV2Lens(): EVM.EVMAddress? { + return self.account.storage.copy(from: /storage/MorphoERC4626VaultV2Lens) + } + /// Swapper /// /// An implementation of the DeFiActions.Swapper interface to swap assets to 4626 shares where the input token is @@ -172,6 +184,14 @@ access(all) contract MorphoERC4626SwapConnectors { ) if let requiredSharesEVM = ERC4626Utils.previewWithdraw(vault: self.vaultEVMAddress, assets: desiredAssetsEVM) { + if !self.canServiceRedemption(assetsEVM: desiredAssetsEVM) { + return SwapConnectors.BasicQuote( + inType: self.vaultType, + outType: self.assetType, + inAmount: 0.0, + outAmount: 0.0 + ) + } let maxSharesEVM = FlowEVMBridgeUtils.convertCadenceAmountToERC20Amount( UFix64.max, erc20Address: self.vaultEVMAddress @@ -199,6 +219,56 @@ access(all) contract MorphoERC4626SwapConnectors { ) } + /// Returns true if the ERC4626 vault can currently service a redemption of the given amount of assets. + /// + /// When a VaultV2Lens is configured (see getVaultV2Lens), its maxWithdraw is authoritative: it reports + /// idle assets plus the liquidity available through the vault's liquidity adapter, covering + /// adapter-backed Vault V2 vaults as well. If no lens is configured or the lens call fails, the + /// heuristic below applies. + /// + /// Morpho Vault V2 vaults serve redemptions from their idle asset balance, topped up by a configured + /// liquidity adapter when idle is insufficient (see VaultV2.exit). When the vault exposes + /// liquidityAdapter() and it is the zero address, redemptions are served from idle assets only and the + /// redemption is servicable iff it does not exceed the vault's idle balance. + /// + /// When the vault does not expose liquidityAdapter() (e.g. MetaMorpho V1, plain ERC4626) or has an + /// adapter set, adapter/market liquidity cannot be measured generically and the legacy ungated behavior + /// is preserved. When the vault has no adapter but the idle balance cannot be queried, the redemption is + /// treated as unservicable: a 0.0 quote is a graceful failure that MultiSwapper routes around, while a + /// false positive reverts the whole transaction at execution time. + /// + /// Motivation: previewRedeem/previewWithdraw quote full NAV regardless of liquidity, so the redeem leg + /// always won MultiSwapper quote comparisons and then reverted at execution for illiquid vaults (and + /// Source.minimumAvailable downstream advertised exits that could not be served). + /// + /// @param assetsEVM The redemption's asset amount, denominated in the underlying asset's EVM decimals + /// + /// @return true if the redemption is servicable (or servicability cannot be ruled out), false otherwise + /// + access(self) fun canServiceRedemption(assetsEVM: UInt256): Bool { + // A configured VaultV2Lens is authoritative: it reports idle assets plus the liquidity available + // through the vault's liquidity adapter (VaultV2 itself hardcodes maxWithdraw/maxRedeem to 0). + // If the lens call fails - e.g. the vault is not a VaultV2 - fall through to the heuristic below. + if let lens = MorphoERC4626SwapConnectors.getVaultV2Lens() { + if let maxAssets = ERC4626Utils.maxWithdrawViaLens(lens: lens, vault: self.vaultEVMAddress) { + return assetsEVM <= maxAssets + } + } + if let adapter = ERC4626Utils.liquidityAdapter(vault: self.vaultEVMAddress) { + if adapter.toString() != "0000000000000000000000000000000000000000" { + // adapter-backed vault: adapter liquidity is not measured, preserve legacy routing + return true + } + // no adapter: redemptions are served from the vault's idle asset balance only + if let idle = ERC4626Utils.idleAssets(vault: self.vaultEVMAddress) { + return assetsEVM <= idle + } + return false + } + // vault does not expose liquidityAdapter() - preserve legacy ungated behavior + return true + } + // -------------------------------------------------------------------- // Direction model // @@ -279,6 +349,14 @@ access(all) contract MorphoERC4626SwapConnectors { ) if let assetsOutEVM = ERC4626Utils.previewRedeem(vault: self.vaultEVMAddress, shares: providedSharesEVM) { + if !self.canServiceRedemption(assetsEVM: assetsOutEVM) { + return SwapConnectors.BasicQuote( + inType: self.vaultType, + outType: self.assetType, + inAmount: 0.0, + outAmount: 0.0 + ) + } let assetDecimals = FlowEVMBridgeUtils.getTokenDecimals(evmContractAddress: self.assetEVMAddress) let assetsOut = EVMAmountUtils.toCadenceOut( assetsOutEVM, diff --git a/cadence/contracts/utils/ERC4626Utils.cdc b/cadence/contracts/utils/ERC4626Utils.cdc index 8128bcab..25258407 100644 --- a/cadence/contracts/utils/ERC4626Utils.cdc +++ b/cadence/contracts/utils/ERC4626Utils.cdc @@ -97,6 +97,65 @@ access(all) contract ERC4626Utils { return maxRedeem[0] as! UInt256 } + /// Returns the amount of the underlying asset held by the ERC4626 vault itself, i.e. its idle asset balance. + /// For vaults that serve redemptions from idle assets (e.g. a Morpho Vault V2 with no liquidity adapter + /// configured), this is the maximum redemption the vault can currently service. + /// + /// @param vault The address of the ERC4626 vault + /// + /// @return The vault's idle balance of the underlying asset, in the asset's decimals, or nil if the vault's + /// underlying asset cannot be resolved or the call fails. + access(all) + fun idleAssets(vault: EVM.EVMAddress): UInt256? { + if let asset = self.underlyingAssetEVMAddress(vault: vault) { + let callRes = self._dryCall(to: asset, signature: "balanceOf(address)", args: [vault], gasLimit: 5_000_000) + if callRes.status != EVM.Status.successful || callRes.data.length == 0 { + return nil + } + let decoded = EVM.decodeABI(types: [Type()], data: callRes.data) + return decoded[0] as! UInt256 + } + return nil + } + + /// Returns the liquidity adapter configured on a Morpho Vault V2-style ERC4626 vault. A zero address means the + /// vault has no liquidity adapter and serves redemptions from its idle asset balance only (see VaultV2.exit). + /// + /// @param vault The address of the ERC4626 vault + /// + /// @return The liquidity adapter address, or nil if the vault does not expose liquidityAdapter() (e.g. + /// MetaMorpho V1 or a plain ERC4626) or the call fails. Callers must distinguish nil (unknown) from + /// the zero address (provably no adapter). + access(all) + fun liquidityAdapter(vault: EVM.EVMAddress): EVM.EVMAddress? { + let callRes = self._dryCall(to: vault, signature: "liquidityAdapter()", args: [], gasLimit: 5_000_000) + if callRes.status != EVM.Status.successful || callRes.data.length == 0 { + return nil + } + let decoded = EVM.decodeABI(types: [Type()], data: callRes.data) + return decoded[0] as! EVM.EVMAddress + } + + /// Returns the maximum assets immediately withdrawable from a Morpho Vault V2 vault as reported by a + /// VaultV2Lens contract: idle assets plus liquidity available through the vault's liquidity adapter. + /// VaultV2 itself hardcodes maxWithdraw/maxRedeem to 0 ("revert-free cannot be guaranteed when calling + /// the gate"), so the lens is the supported way to query servicable liquidity. + /// + /// @param lens The address of the VaultV2Lens contract + /// @param vault The address of the VaultV2 vault + /// + /// @return The withdrawable assets in the underlying asset's decimals, or nil if the call fails - e.g. + /// when the vault is not a VaultV2 (the lens reverts on vaults without liquidityAdapter()). + access(all) + fun maxWithdrawViaLens(lens: EVM.EVMAddress, vault: EVM.EVMAddress): UInt256? { + let callRes = self._dryCall(to: lens, signature: "maxWithdraw(address)", args: [vault], gasLimit: 5_000_000) + if callRes.status != EVM.Status.successful || callRes.data.length == 0 { + return nil + } + let decoded = EVM.decodeABI(types: [Type()], data: callRes.data) + return decoded[0] as! UInt256 + } + /// Returns the maximum amount of assets that can be deposited into the ERC4626 vault /// /// @param vault The address of the ERC4626 vault diff --git a/cadence/tests/MorphoERC4626SwapConnectors_test.cdc b/cadence/tests/MorphoERC4626SwapConnectors_test.cdc index 82e78dea..70e72849 100644 --- a/cadence/tests/MorphoERC4626SwapConnectors_test.cdc +++ b/cadence/tests/MorphoERC4626SwapConnectors_test.cdc @@ -7,6 +7,9 @@ import "DeFiActions" // testing account access(all) let testAccount = Test.getAccount(0x443472749ebdaac8) +// the MorphoERC4626SwapConnectors contract account, signable on the fork via the local key in flow.json +// (mainnet-fork-morpho-erc4626-connectors) so tests can write lens config to the contract account's storage +access(all) let connectorAccount = Test.getAccount(0x251032a66e9700ef) // FUSDEV MorphoERC4626 vault (underlying asset: PYUSD0) access(all) let morphoERC4626VaultEVMAddressHex = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" @@ -75,6 +78,70 @@ access(all) fun testQuoteIn() { let quote = quoteInResult.returnValue! as! {DeFiActions.Quote} assert(quote.inAmount > 1.0, message: "Share should be at least 1.0 PYUSD0") } +access(all) fun testQuoteOutSharesToAssetsGatedByRedeemableLiquidity() { + // a servicable amount quotes normally - the liquidity gate is transparent when the vault can pay out + let servicableResult = _executeScript( + "./scripts/morpho/quote_out.cdc", + [ + testAccount.address, + morphoERC4626VaultEVMAddressHex, + 1.0 + ] + ) + Test.expect(servicableResult, Test.beSucceeded()) + let servicableQuote = servicableResult.returnValue! as! {DeFiActions.Quote} + assert(servicableQuote.outAmount > 0.0, message: "1.0 share should quote a non-zero amount of PYUSD0") + + // an amount orders of magnitude beyond the vault's total share supply can never be redeemed - the quote must + // gracefully fail with 0.0 (letting MultiSwapper route to another Swapper) instead of advertising NAV that + // the vault cannot pay out + let unservicableResult = _executeScript( + "./scripts/morpho/quote_out.cdc", + [ + testAccount.address, + morphoERC4626VaultEVMAddressHex, + 10_000_000_000.0 + ] + ) + Test.expect(unservicableResult, Test.beSucceeded()) + let unservicableQuote = unservicableResult.returnValue! as! {DeFiActions.Quote} + assert(unservicableQuote.inAmount == 0.0, message: "Unservicable redemption must quote 0.0 inAmount") + assert(unservicableQuote.outAmount == 0.0, message: "Unservicable redemption must quote 0.0 outAmount") +} +access(all) fun testQuoteOutGatedByConfiguredVaultV2Lens() { + // NOTE: the real VaultV2Lens (0x772F1Fe931F5803f2eD48559a7311574db930070) was deployed after the pinned + // fork height, so its integration is verified against live mainnet instead - see the PR description. + // + // Negative control: configure the vault itself as the lens. VaultV2 hardcodes maxWithdraw to 0, so the + // quote can only come back 0.0 if the configured-lens branch executed - the heuristic path would quote + // non-zero at this height since idle covers 1.0 share. + let setLens = _executeTransaction( + "../transactions/evm/morpho/set_vault_v2_lens.cdc", + [morphoERC4626VaultEVMAddressHex as String?], + connectorAccount + ) + Test.expect(setLens, Test.beSucceeded()) + + let quoteResult = _executeScript( + "./scripts/morpho/quote_out.cdc", + [ + testAccount.address, + morphoERC4626VaultEVMAddressHex, + 1.0 + ] + ) + Test.expect(quoteResult, Test.beSucceeded()) + let quote = quoteResult.returnValue! as! {DeFiActions.Quote} + assert(quote.outAmount == 0.0, message: "Configured lens reporting 0 liquidity must zero the quote") + + // clear the config + let clearLens = _executeTransaction( + "../transactions/evm/morpho/set_vault_v2_lens.cdc", + [nil as String?], + connectorAccount + ) + Test.expect(clearLens, Test.beSucceeded()) +} access(all) fun testSwap() { let swapRes = _executeTransaction( @@ -87,11 +154,22 @@ access(all) fun testSwap() { ) Test.expect(swapRes, Test.beSucceeded()) + // swap back the full received share balance: depositing 1.0 PYUSD0 yields slightly fewer than 1.0 + // share (previewDeposit rounds down, and Cadence<->EVM conversion truncates to the share token's + // decimals), so any hardcoded amount is height-dependent + let balanceResult = _executeScript( + "./scripts/morpho/get_share_balance.cdc", + [testAccount.address] + ) + Test.expect(balanceResult, Test.beSucceeded()) + let shareBalance = balanceResult.returnValue! as! UFix64 + assert(shareBalance > 0.0, message: "Expected non-zero share balance after swap") + let swapBackRes = _executeTransaction( "./transactions/morpho/swap_back.cdc", [ morphoERC4626VaultEVMAddressHex, - 0.99 // @TODO investigage losses + shareBalance ], testAccount ) diff --git a/cadence/tests/scripts/morpho/get_share_balance.cdc b/cadence/tests/scripts/morpho/get_share_balance.cdc new file mode 100644 index 00000000..abf8f1e4 --- /dev/null +++ b/cadence/tests/scripts/morpho/get_share_balance.cdc @@ -0,0 +1,12 @@ +import "FungibleToken" + +/// Returns the balance of the given account's bridged FUSDEV (euSDEV) share vault +/// +/// @param account: The address of the account holding the share vault +/// +access(all) fun main(account: Address): UFix64 { + let vault = getAccount(account).capabilities.borrow<&{FungibleToken.Balance}>( + /public/EVMVMBridgedToken_d069d989e2f44b70c65347d1853c0c67e10a9f8dVault + ) ?? panic("Missing bridged share vault public capability") + return vault.balance +} diff --git a/cadence/tests/scripts/morpho/quote_out.cdc b/cadence/tests/scripts/morpho/quote_out.cdc new file mode 100644 index 00000000..b87b7254 --- /dev/null +++ b/cadence/tests/scripts/morpho/quote_out.cdc @@ -0,0 +1,49 @@ +import "FungibleToken" +import "FlowToken" +import "EVM" +import "ERC4626Utils" +import "DeFiActions" +import "FungibleTokenConnectors" +import "FlowEVMBridgeConfig" +import "MorphoERC4626SwapConnectors" + +/// Returns a quote for the amount of assets received for the provided amount of shares (shares -> assets direction) +/// +/// @param erc4626VaultEVMAddressHex: The EVM address of the ERC4626 vault as a hex string +/// @param providedShares: The amount of shares to provide +/// +access(all) fun main( + coaHost: Address, + erc4626VaultEVMAddressHex: String, + providedShares: UFix64 +): {DeFiActions.Quote} { + let erc4626VaultEVMAddress = EVM.addressFromString(erc4626VaultEVMAddressHex) + + let acct = getAuthAccount(coaHost) + + // get the COA capability + let coa = acct.capabilities.storage.issue(/storage/evm) + + // create a fee source + let feeVault = acct.capabilities.storage.issue( + /storage/flowTokenVault + ) + let feeSource = FungibleTokenConnectors.VaultSinkAndSource( + min: nil, + max: nil, + vault: feeVault, + uniqueID: nil + ) + + // create the Swapper + let swapper = MorphoERC4626SwapConnectors.Swapper( + vaultEVMAddress: erc4626VaultEVMAddress, + coa: coa, + feeSource: feeSource, + uniqueID: nil, + isReversed: false, + ) + + // get the quote for the provided shares in the shares -> assets direction + return swapper.quoteOut(forProvided: providedShares, reverse: true) +} diff --git a/cadence/transactions/evm/morpho/set_vault_v2_lens.cdc b/cadence/transactions/evm/morpho/set_vault_v2_lens.cdc new file mode 100644 index 00000000..59d1cc32 --- /dev/null +++ b/cadence/transactions/evm/morpho/set_vault_v2_lens.cdc @@ -0,0 +1,17 @@ +import "EVM" + +/// Sets or clears the VaultV2Lens EVM address used by MorphoERC4626SwapConnectors to gate redemption quotes +/// on servicable liquidity. The address is stored in the contract account's storage (not contract state) so it +/// can be changed without a contract update. Must be signed by the MorphoERC4626SwapConnectors contract account. +/// +/// @param lensEVMAddressHex: The VaultV2Lens EVM address as a hex string, or nil to clear the configuration and +/// revert to the liquidityAdapter/idle-assets heuristic. +/// +transaction(lensEVMAddressHex: String?) { + prepare(signer: auth(Storage) &Account) { + let existing = signer.storage.load(from: /storage/MorphoERC4626VaultV2Lens) + if let hex = lensEVMAddressHex { + signer.storage.save(EVM.addressFromString(hex), to: /storage/MorphoERC4626VaultV2Lens) + } + } +}