Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<EVM.EVMAddress>(from: /storage/MorphoERC4626VaultV2Lens)
}

/// Swapper
///
/// An implementation of the DeFiActions.Swapper interface to swap assets to 4626 shares where the input token is
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
//
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions cadence/contracts/utils/ERC4626Utils.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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<UInt256>()], 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<EVM.EVMAddress>()], 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<UInt256>()], 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
Expand Down
80 changes: 79 additions & 1 deletion cadence/tests/MorphoERC4626SwapConnectors_test.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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(
Expand All @@ -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
)
Expand Down
12 changes: 12 additions & 0 deletions cadence/tests/scripts/morpho/get_share_balance.cdc
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions cadence/tests/scripts/morpho/quote_out.cdc
Original file line number Diff line number Diff line change
@@ -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<auth(Storage, Capabilities) &Account>(coaHost)

// get the COA capability
let coa = acct.capabilities.storage.issue<auth(EVM.Call, EVM.Bridge) &EVM.CadenceOwnedAccount>(/storage/evm)

// create a fee source
let feeVault = acct.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>(
/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)
}
17 changes: 17 additions & 0 deletions cadence/transactions/evm/morpho/set_vault_v2_lens.cdc
Original file line number Diff line number Diff line change
@@ -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<EVM.EVMAddress>(from: /storage/MorphoERC4626VaultV2Lens)
if let hex = lensEVMAddressHex {
signer.storage.save(EVM.addressFromString(hex), to: /storage/MorphoERC4626VaultV2Lens)
}
}
}
Loading