From 885f217b9b93e1a007d275d99d85d823cf5490a6 Mon Sep 17 00:00:00 2001 From: Kan Zhang Date: Mon, 31 Aug 2026 14:06:40 -0700 Subject: [PATCH 1/4] 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. --- .../morpho/MorphoERC4626SwapConnectors.cdc | 53 +++++++++++++++++++ cadence/contracts/utils/ERC4626Utils.cdc | 39 ++++++++++++++ .../MorphoERC4626SwapConnectors_test.cdc | 30 +++++++++++ cadence/tests/scripts/morpho/quote_out.cdc | 49 +++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 cadence/tests/scripts/morpho/quote_out.cdc diff --git a/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc b/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc index 40cf8417..b1d6d26a 100644 --- a/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc +++ b/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc @@ -172,6 +172,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 +207,43 @@ access(all) contract MorphoERC4626SwapConnectors { ) } + /// Returns true if the ERC4626 vault can currently service a redemption of the given amount of assets. + /// + /// 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 { + 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 +324,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..4ca92ada 100644 --- a/cadence/contracts/utils/ERC4626Utils.cdc +++ b/cadence/contracts/utils/ERC4626Utils.cdc @@ -97,6 +97,45 @@ 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 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..366573c7 100644 --- a/cadence/tests/MorphoERC4626SwapConnectors_test.cdc +++ b/cadence/tests/MorphoERC4626SwapConnectors_test.cdc @@ -75,6 +75,36 @@ 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 testSwap() { let swapRes = _executeTransaction( 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) +} From 85895c1bab08809131f4c28e5248a953abeeeef8 Mon Sep 17 00:00:00 2001 From: Kan Zhang Date: Mon, 31 Aug 2026 14:37:33 -0700 Subject: [PATCH 2/4] 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). --- .../morpho/MorphoERC4626SwapConnectors.cdc | 25 +++++++ cadence/contracts/utils/ERC4626Utils.cdc | 20 +++++ .../MorphoERC4626SwapConnectors_test.cdc | 73 ++++++++++++++++++- .../evm/morpho/set_vault_v2_lens.cdc | 17 +++++ 4 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 cadence/transactions/evm/morpho/set_vault_v2_lens.cdc diff --git a/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc b/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc index b1d6d26a..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 @@ -209,6 +221,11 @@ 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 @@ -229,6 +246,14 @@ access(all) contract MorphoERC4626SwapConnectors { /// @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 diff --git a/cadence/contracts/utils/ERC4626Utils.cdc b/cadence/contracts/utils/ERC4626Utils.cdc index 4ca92ada..25258407 100644 --- a/cadence/contracts/utils/ERC4626Utils.cdc +++ b/cadence/contracts/utils/ERC4626Utils.cdc @@ -136,6 +136,26 @@ access(all) contract ERC4626Utils { 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 366573c7..22cc7b1c 100644 --- a/cadence/tests/MorphoERC4626SwapConnectors_test.cdc +++ b/cadence/tests/MorphoERC4626SwapConnectors_test.cdc @@ -1,4 +1,4 @@ -#test_fork(network: "mainnet-fork", height: 142104481) +#test_fork(network: "mainnet-fork", height: 163054070) import Test @@ -7,8 +7,13 @@ 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" +// VaultV2Lens deployed on mainnet after the previously pinned fork height; requires a recent fork height +access(all) let vaultV2LensEVMAddressHex = "0x772f1fe931f5803f2ed48559a7311574db930070" /* --- Test Helpers --- */ @@ -105,6 +110,70 @@ access(all) fun testQuoteOutSharesToAssetsGatedByRedeemableLiquidity() { 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() { + // 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) + var setLens = _executeTransaction( + "../transactions/evm/morpho/set_vault_v2_lens.cdc", + [morphoERC4626VaultEVMAddressHex as String?], + connectorAccount + ) + Test.expect(setLens, Test.beSucceeded()) + + var quoteResult = _executeScript( + "./scripts/morpho/quote_out.cdc", + [ + testAccount.address, + morphoERC4626VaultEVMAddressHex, + 1.0 + ] + ) + Test.expect(quoteResult, Test.beSucceeded()) + var quote = quoteResult.returnValue! as! {DeFiActions.Quote} + assert(quote.outAmount == 0.0, message: "Configured lens reporting 0 liquidity must zero the quote") + + // positive: configure the real VaultV2Lens - a servicable amount quotes, an amount beyond servicable + // liquidity (~4.8k PYUSD0 at the pinned height) does not + setLens = _executeTransaction( + "../transactions/evm/morpho/set_vault_v2_lens.cdc", + [vaultV2LensEVMAddressHex as String?], + connectorAccount + ) + Test.expect(setLens, Test.beSucceeded()) + + quoteResult = _executeScript( + "./scripts/morpho/quote_out.cdc", + [ + testAccount.address, + morphoERC4626VaultEVMAddressHex, + 1.0 + ] + ) + Test.expect(quoteResult, Test.beSucceeded()) + quote = quoteResult.returnValue! as! {DeFiActions.Quote} + assert(quote.outAmount > 0.0, message: "1.0 share should quote non-zero with the real lens configured") + + quoteResult = _executeScript( + "./scripts/morpho/quote_out.cdc", + [ + testAccount.address, + morphoERC4626VaultEVMAddressHex, + 10_000.0 + ] + ) + Test.expect(quoteResult, Test.beSucceeded()) + quote = quoteResult.returnValue! as! {DeFiActions.Quote} + assert(quote.outAmount == 0.0, message: "Amount beyond lens-reported liquidity must quote 0.0") + + // 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( @@ -121,7 +190,7 @@ access(all) fun testSwap() { "./transactions/morpho/swap_back.cdc", [ morphoERC4626VaultEVMAddressHex, - 0.99 // @TODO investigage losses + 0.94 // 1.0 PYUSD0 deposits ~0.947 shares at the pinned height's exchange rate (~1.056 PYUSD0/share) ], testAccount ) 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) + } + } +} From 5685dfdfc9c62d70609402de21665c769861c2e1 Mon Sep 17 00:00:00 2001 From: Kan Zhang Date: Mon, 31 Aug 2026 15:00:41 -0700 Subject: [PATCH 3/4] 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. --- .../MorphoERC4626SwapConnectors_test.cdc | 54 ++++--------------- 1 file changed, 11 insertions(+), 43 deletions(-) diff --git a/cadence/tests/MorphoERC4626SwapConnectors_test.cdc b/cadence/tests/MorphoERC4626SwapConnectors_test.cdc index 22cc7b1c..4ce9dc9a 100644 --- a/cadence/tests/MorphoERC4626SwapConnectors_test.cdc +++ b/cadence/tests/MorphoERC4626SwapConnectors_test.cdc @@ -1,4 +1,4 @@ -#test_fork(network: "mainnet-fork", height: 163054070) +#test_fork(network: "mainnet-fork", height: 142104481) import Test @@ -12,8 +12,6 @@ access(all) let testAccount = Test.getAccount(0x443472749ebdaac8) access(all) let connectorAccount = Test.getAccount(0x251032a66e9700ef) // FUSDEV MorphoERC4626 vault (underlying asset: PYUSD0) access(all) let morphoERC4626VaultEVMAddressHex = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" -// VaultV2Lens deployed on mainnet after the previously pinned fork height; requires a recent fork height -access(all) let vaultV2LensEVMAddressHex = "0x772f1fe931f5803f2ed48559a7311574db930070" /* --- Test Helpers --- */ @@ -111,17 +109,20 @@ access(all) fun testQuoteOutSharesToAssetsGatedByRedeemableLiquidity() { assert(unservicableQuote.outAmount == 0.0, message: "Unservicable redemption must quote 0.0 outAmount") } access(all) fun testQuoteOutGatedByConfiguredVaultV2Lens() { - // 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) - var setLens = _executeTransaction( + // 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()) - var quoteResult = _executeScript( + let quoteResult = _executeScript( "./scripts/morpho/quote_out.cdc", [ testAccount.address, @@ -130,42 +131,9 @@ access(all) fun testQuoteOutGatedByConfiguredVaultV2Lens() { ] ) Test.expect(quoteResult, Test.beSucceeded()) - var quote = quoteResult.returnValue! as! {DeFiActions.Quote} + let quote = quoteResult.returnValue! as! {DeFiActions.Quote} assert(quote.outAmount == 0.0, message: "Configured lens reporting 0 liquidity must zero the quote") - // positive: configure the real VaultV2Lens - a servicable amount quotes, an amount beyond servicable - // liquidity (~4.8k PYUSD0 at the pinned height) does not - setLens = _executeTransaction( - "../transactions/evm/morpho/set_vault_v2_lens.cdc", - [vaultV2LensEVMAddressHex as String?], - connectorAccount - ) - Test.expect(setLens, Test.beSucceeded()) - - quoteResult = _executeScript( - "./scripts/morpho/quote_out.cdc", - [ - testAccount.address, - morphoERC4626VaultEVMAddressHex, - 1.0 - ] - ) - Test.expect(quoteResult, Test.beSucceeded()) - quote = quoteResult.returnValue! as! {DeFiActions.Quote} - assert(quote.outAmount > 0.0, message: "1.0 share should quote non-zero with the real lens configured") - - quoteResult = _executeScript( - "./scripts/morpho/quote_out.cdc", - [ - testAccount.address, - morphoERC4626VaultEVMAddressHex, - 10_000.0 - ] - ) - Test.expect(quoteResult, Test.beSucceeded()) - quote = quoteResult.returnValue! as! {DeFiActions.Quote} - assert(quote.outAmount == 0.0, message: "Amount beyond lens-reported liquidity must quote 0.0") - // clear the config let clearLens = _executeTransaction( "../transactions/evm/morpho/set_vault_v2_lens.cdc", @@ -190,7 +158,7 @@ access(all) fun testSwap() { "./transactions/morpho/swap_back.cdc", [ morphoERC4626VaultEVMAddressHex, - 0.94 // 1.0 PYUSD0 deposits ~0.947 shares at the pinned height's exchange rate (~1.056 PYUSD0/share) + 0.99 // @TODO investigage losses ], testAccount ) From 346995074a4c4521c4eab2b0b11f54bf65ea41ee Mon Sep 17 00:00:00 2001 From: Kan Zhang Date: Mon, 31 Aug 2026 15:54:15 -0700 Subject: [PATCH 4/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. --- cadence/tests/MorphoERC4626SwapConnectors_test.cdc | 13 ++++++++++++- cadence/tests/scripts/morpho/get_share_balance.cdc | 12 ++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 cadence/tests/scripts/morpho/get_share_balance.cdc diff --git a/cadence/tests/MorphoERC4626SwapConnectors_test.cdc b/cadence/tests/MorphoERC4626SwapConnectors_test.cdc index 4ce9dc9a..70e72849 100644 --- a/cadence/tests/MorphoERC4626SwapConnectors_test.cdc +++ b/cadence/tests/MorphoERC4626SwapConnectors_test.cdc @@ -154,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 +}