diff --git a/protocols/morpho/README.md b/protocols/morpho/README.md index 7a422be..f0082f0 100644 --- a/protocols/morpho/README.md +++ b/protocols/morpho/README.md @@ -137,7 +137,7 @@ If any market's allocation exceeds its adjusted threshold, an alert is triggered Morpho's [Vault V2](https://github.com/morpho-org/vault-v2) replaces the v1 single-vault timelock with a **per-function timelock** keyed by arbitrary calldata, plus a richer adapter system. Yearn-curated v2 vaults are monitored separately: - [`governance_v2.py`](./governance_v2.py) — daily, pulls a per-vault governance **snapshot** from Morpho's GraphQL API (`vaultV2s.pendingConfigs` + `owner` / `curator` / `sentinels` / `allocators` / `adapters`) and diffs it against the persisted cache. Mirrors v1's pull-based approach (`pendingTimelock` / `pendingGuardian` / `pendingCap`) so RPC usage stays bounded. Alerts on: new pending timelocked operations, executed or revoked operations, owner / curator changes, sentinel / allocator / adapter set changes. -- [`markets_v2.py`](./markets_v2.py) — hourly, walks each v2 vault's adapters and applies the shared [risk.py](./risk.py) policy against underlying Morpho Blue markets when the vault uses `MorphoMarketV1AdapterV2`. It also checks the API's immediately withdrawable `liquidityUsd` against the shared 1% threshold. V2 vaults used by YV-collateral strategies skip the individual threshold because `markets.py` performs the more relevant combined collateral-at-risk coverage check. For `MorphoVaultV1Adapter`, the wrapped v1 vault keeps receiving its full v1 analysis via `markets.py`; we only flag a wrapped v1 vault absent from `VAULTS_V1_BY_CHAIN`. +- [`markets_v2.py`](./markets_v2.py) — hourly, GraphQL-only (no RPC): one `vaultV2s` query loads TVL, liquidity, and `MorphoMarketV1` adapter positions for every configured vault, then one `markets` query per chain loads state/bad debt. Applies the shared [risk.py](./risk.py) policy using each position's `supplyAssetsUsd`, and checks withdrawable `liquidityUsd` against the shared 1% threshold. V2 vaults used by YV-collateral strategies skip the individual liquidity threshold because `markets.py` performs the combined collateral-at-risk coverage check. Non-`MorphoMarketV1` adapters fail the run (configured vaults are market-adapter only). - [`v2_decoders.py`](./v2_decoders.py) — selector→signature map and decoders for every v2 timelocked function (and the three `idData` tag prefixes used by `increaseAbsoluteCap`/`increaseRelativeCap`). ### Vault list diff --git a/protocols/morpho/config.py b/protocols/morpho/config.py index 666a48e..3066f42 100644 --- a/protocols/morpho/config.py +++ b/protocols/morpho/config.py @@ -76,7 +76,6 @@ class VaultConfig: Chain.BASE: ( VaultConfig("Yearn OG USDC V2", "0xe7D0DBE3493830e2Ab62619211A2BfF0Fc60dB42", 2), VaultConfig("Yearn OG WETH V2", "0x2EfD54529329AD364B8Df988CE3BAb5Ff256ab3E", 2), - VaultConfig("OUSD Vault V2", "0x2Ba14b2e1E7D2189D3550b708DFCA01f899f33c1", 2), ), Chain.KATANA: ( VaultConfig("Yearn OG USDC", "0xca44cbe1FB03691d43d2d93AA460e2fCB03878fE", 1, KATANA_USDC), diff --git a/protocols/morpho/markets_v2.py b/protocols/morpho/markets_v2.py index e0d6848..a4b7cbd 100644 --- a/protocols/morpho/markets_v2.py +++ b/protocols/morpho/markets_v2.py @@ -1,20 +1,13 @@ """Morpho VaultV2 markets / allocation / risk monitor. -Iterates the explicit ``VAULTS_V2_BY_CHAIN`` list (Yearn-curated V2 vaults), then -for each vault reads its adapters on-chain and: - -* For ``MorphoVaultV1Adapter`` (V2 wraps a v1 MetaMorpho vault) — sanity-checks - that the wrapped v1 vault is already monitored. The wrapped v1 vault keeps - receiving its full v1 analysis via ``markets.py``; we only flag the case where - V2 introduces a *new* unknown v1 vault that operators should add. -* For ``MorphoMarketV1AdapterV2`` (V2 wraps Morpho Blue markets directly) — - reads ``expectedSupplyAssets`` per market, fetches market metadata via - GraphQL, and applies the shared risk-tier policy from ``risk.py``. - -Bad debt is pulled per market from the same GraphQL endpoint v1 uses. Normal -Vault V2 liquidity uses the API's immediately withdrawable ``liquidityUsd``; -YV-collateral strategy vaults use the combined v1/v2 coverage check in -``markets.py`` instead. +Fetches every configured V2 vault from Morpho GraphQL in one query (TVL, +liquidity, ``MorphoMarketV1`` adapter positions), then batches market state / +bad debt per chain. No RPC. + +Applies the shared [risk.py](./risk.py) policy against each market's +``supplyAssetsUsd``. Normal Vault V2 liquidity uses the API's immediately +withdrawable ``liquidityUsd``; YV-collateral strategy vaults use the combined +v1/v2 coverage check in ``markets.py`` instead. """ from dataclasses import dataclass, field @@ -34,7 +27,6 @@ require_configured_keys, ) from protocols.morpho.config import ( - VAULTS_V1_BY_CHAIN, VAULTS_V2_BY_CHAIN, get_vault_query_config, is_collateral_vault, @@ -47,33 +39,16 @@ get_market_risk_level, is_low_liquidity, ) -from utils.abi import load_abi from utils.alert import Alert, AlertSeverity, send_alert -from utils.cache import ( - get_last_value_for_key_from_file, - morpho_filename, - morpho_key, - write_last_value_to_file, -) from utils.chains import Chain from utils.logger import get_logger -from utils.web3_wrapper import ChainManager, Web3Client logger = get_logger("morpho.markets_v2") -ABI_VAULT_V2 = load_abi("protocols/morpho/abi/vault_v2.json") -ABI_MARKET_ADAPTER = load_abi("protocols/morpho/abi/morpho_market_v1_adapter_v2.json") -ABI_VAULT_ADAPTER = load_abi("protocols/morpho/abi/morpho_vault_v1_adapter.json") - -ADAPTER_KIND_MARKET = "MorphoMarketV1AdapterV2" -ADAPTER_KIND_VAULT = "MorphoVaultV1Adapter" - -# Cache tag for "this wrapped v1 vault has already been flagged as unmonitored" — -# without this, every hourly run would re-spam the channel. -VAULT_ADAPTER_SEEN_TYPE = "v2_vault_adapter_seen" - -# Ignore wrapped-v1 adapter sanity checks when exposure is negligible. -MIN_VAULT_ADAPTER_ALLOCATION_RATIO = 0.01 +ADAPTER_TYPE_MARKET = "MorphoMarketV1" +# GraphQL complexity scales with these `first` args — keep headroom under 1M. +MAX_ADAPTERS_PER_VAULT = 3 +MAX_POSITIONS_PER_ADAPTER = 20 @dataclass @@ -85,23 +60,11 @@ class V2Vault: chain: Chain asset_address: str asset_symbol: str - curator: str - owner: str risk_level: int total_assets_usd: float = 0.0 liquidity_usd: float = 0.0 - graphql_adapters: List[Dict[str, Any]] = field(default_factory=list) - - -@dataclass -class AdapterInfo: - """Result of on-chain adapter classification.""" - - address: str - kind: str - wrapped_v1_vault: Optional[str] = None # set for MorphoVaultV1Adapter - market_ids: List[str] = field(default_factory=list) # set for MorphoMarketV1AdapterV2 - expected_supply_assets: List[int] = field(default_factory=list) # parallel to market_ids + # market_id (lowercase) -> vault supply USD from MorphoMarketV1Adapter positions + market_allocations_usd: Dict[str, float] = field(default_factory=dict) @dataclass(frozen=True) @@ -118,42 +81,51 @@ class MarketAssessment: # ---------------------------------------------------------------------------- -_STATE_QUERY = """ -query VaultV2State($addresses: [String!]!, $chainIds: [Int!]!) { - vaultV2s(first: 200, where: { address_in: $addresses, chainId_in: $chainIds }) { - items { +_STATE_QUERY = f""" +query VaultV2State($addresses: [String!]!) {{ + vaultV2s(first: 200, where: {{ address_in: $addresses }}) {{ + items {{ address name - chain { id } - curator { address } - owner { address } - asset { address symbol } + chain {{ id }} + asset {{ address symbol }} totalAssetsUsd liquidityUsd - adapters { - items { address type assetsUsd } - } - } - } -} + adapters(first: {MAX_ADAPTERS_PER_VAULT}) {{ + items {{ + address + type + ... on MorphoMarketV1Adapter {{ + positions(first: {MAX_POSITIONS_PER_ADAPTER}) {{ + items {{ + state {{ supplyAssetsUsd }} + market {{ marketId }} + }} + }} + }} + }} + }} + }} + }} +}} """ def discover_v2_vaults_by_chain() -> Dict[Chain, List[V2Vault]]: - """Load state for every vault declared in ``VAULTS_V2_BY_CHAIN``. + """Load state + market allocations for every vault in ``VAULTS_V2_BY_CHAIN``. - Issues a single GraphQL ``vaultV2s(where: { address_in })`` query, then - joins the result back to the static list to inherit the configured risk - level. Raises if the API omits any configured vault. + Issues a single GraphQL ``vaultV2s`` query with adapter positions, then joins + back to the static list for risk level. Raises if the API omits a configured + vault, returns a non-market adapter, or hits the positions page size. """ - addr_to_meta, addresses, chain_ids = get_vault_query_config(VAULTS_V2_BY_CHAIN) + addr_to_meta, addresses, _chain_ids = get_vault_query_config(VAULTS_V2_BY_CHAIN) if not addresses: return {} data = execute_graphql( _STATE_QUERY, - {"addresses": addresses, "chainIds": chain_ids}, + {"addresses": addresses}, "Vault V2 state", error_type=MorphoV2MonitoringError, ) @@ -176,12 +148,10 @@ def discover_v2_vaults_by_chain() -> Dict[Chain, List[V2Vault]]: chain=chain, asset_address=item["asset"]["address"], asset_symbol=item["asset"]["symbol"], - curator=(item.get("curator") or {}).get("address") or "", - owner=(item.get("owner") or {}).get("address") or "", risk_level=config.risk_level, total_assets_usd=float(item.get("totalAssetsUsd") or 0), liquidity_usd=float(item.get("liquidityUsd") or 0), - graphql_adapters=(item.get("adapters") or {}).get("items") or [], + market_allocations_usd=_parse_market_allocations(item, config.name, chain), ) ) @@ -190,67 +160,43 @@ def discover_v2_vaults_by_chain() -> Dict[Chain, List[V2Vault]]: return result -# ---------------------------------------------------------------------------- -# Adapter classification & on-chain reads -# ---------------------------------------------------------------------------- +def _parse_market_allocations(item: Dict[str, Any], vault_name: str, chain: Chain) -> Dict[str, float]: + """Aggregate MorphoMarketV1 position supply USD by market id.""" + adapters = (item.get("adapters") or {}).get("items") or [] + if len(adapters) >= MAX_ADAPTERS_PER_VAULT: + raise MorphoV2MonitoringError( + f"Vault V2 {vault_name} on {chain.name} returned {len(adapters)} adapters; " + f"raise MAX_ADAPTERS_PER_VAULT (currently {MAX_ADAPTERS_PER_VAULT})" + ) + + allocations: Dict[str, float] = {} + for adapter in adapters: + adapter_type = adapter.get("type") + if adapter_type != ADAPTER_TYPE_MARKET: + raise MorphoV2MonitoringError( + f"Vault V2 {vault_name} on {chain.name} has unsupported adapter type " + f"{adapter_type!r} at {adapter.get('address')} (expected {ADAPTER_TYPE_MARKET})" + ) + positions = (adapter.get("positions") or {}).get("items") or [] + if len(positions) >= MAX_POSITIONS_PER_ADAPTER: + raise MorphoV2MonitoringError( + f"Vault V2 {vault_name} on {chain.name} adapter {adapter.get('address')} " + f"returned {len(positions)} positions; raise MAX_POSITIONS_PER_ADAPTER " + f"(currently {MAX_POSITIONS_PER_ADAPTER})" + ) -def list_adapters(client: Web3Client, vault_address: str) -> List[str]: - """Return the list of adapter addresses currently registered on a V2 vault.""" - vault = client.get_contract(vault_address, ABI_VAULT_V2) - try: - length = vault.functions.adaptersLength().call() - except Exception as e: - raise MorphoV2MonitoringError(f"Failed to read adaptersLength() for Vault V2 {vault_address}: {e}") from e - if length == 0: - return [] - with client.batch_requests() as batch: - for i in range(length): - batch.add(vault.functions.adapters(i)) - responses = client.execute_batch(batch) - return [Web3.to_checksum_address(addr) for addr in responses] - - -def classify_adapter(client: Web3Client, adapter_address: str) -> AdapterInfo: - """Detect whether an adapter is a market-v1 or vault-v1 adapter and pull view data.""" - market_adapter = client.get_contract(adapter_address, ABI_MARKET_ADAPTER) - try: - market_ids_length = market_adapter.functions.marketIdsLength().call() - except Exception: - market_ids_length = None - - if market_ids_length is not None: - market_ids: List[str] = [] - if market_ids_length > 0: - with client.batch_requests() as batch: - for i in range(market_ids_length): - batch.add(market_adapter.functions.marketIds(i)) - market_ids = ["0x" + bytes(mid).hex() for mid in client.execute_batch(batch)] - - expected_assets: List[int] = [] - if market_ids: - with client.batch_requests() as batch: - for mid in market_ids: - batch.add(market_adapter.functions.expectedSupplyAssets(bytes.fromhex(mid[2:]))) - expected_assets = list(client.execute_batch(batch)) - - return AdapterInfo( - address=adapter_address, - kind=ADAPTER_KIND_MARKET, - market_ids=market_ids, - expected_supply_assets=expected_assets, - ) + for position in positions: + market_id = ((position.get("market") or {}).get("marketId") or "").lower() + supply_usd = float((position.get("state") or {}).get("supplyAssetsUsd") or 0) + # Skip dust/idle positions (same idea as v1's >$10k active-market filter): + # zero vault supply means no allocation/risk contribution and no vault-level + # bad-debt alert for that market. + if not market_id or supply_usd <= 0: + continue + allocations[market_id] = allocations.get(market_id, 0.0) + supply_usd - vault_adapter = client.get_contract(adapter_address, ABI_VAULT_ADAPTER) - try: - wrapped = vault_adapter.functions.morphoVaultV1().call() - return AdapterInfo( - address=adapter_address, - kind=ADAPTER_KIND_VAULT, - wrapped_v1_vault=Web3.to_checksum_address(wrapped), - ) - except Exception as e: - raise MorphoV2MonitoringError(f"Adapter {adapter_address} could not be classified: {e}") from e + return allocations # ---------------------------------------------------------------------------- @@ -260,30 +206,19 @@ def classify_adapter(client: Web3Client, adapter_address: str) -> AdapterInfo: def score_market_allocations( vault: V2Vault, - market_adapters: List[AdapterInfo], - vault_total_assets_usd: float, + metrics: Dict[str, MarketMetrics], ) -> None: - """Aggregate per-market allocations across **all** market-v1 adapters and alert. - - The vault-level risk score must aggregate every adapter's exposure before - comparing to ``MAX_RISK_THRESHOLDS`` — otherwise a curator can split a - risky position across two adapters and dodge the threshold per adapter - while the vault as a whole exceeds it. - """ - if vault_total_assets_usd <= 0 or not market_adapters: - return - - underlying_per_market = _aggregate_expected_assets(market_adapters) - if not underlying_per_market: + """Score every market allocation on a vault and emit consolidated alerts.""" + # TVL floor is enforced in analyze_v2_vault; empty allocations are a no-op. + if not vault.market_allocations_usd: return - metrics = fetch_market_metrics(list(underlying_per_market.keys()), vault.chain) total_risk_score = 0.0 allocation_violations: list[str] = [] bad_debt_alerts: list[str] = [] - for market_id, expected_assets in underlying_per_market.items(): - assessment = _assess_market(vault, market_id, expected_assets, metrics, vault_total_assets_usd) + for market_id, allocation_usd in vault.market_allocations_usd.items(): + assessment = _assess_market(vault, market_id, allocation_usd, metrics) if assessment is None: continue total_risk_score += assessment.risk_score @@ -295,25 +230,14 @@ def score_market_allocations( _send_market_assessment_alerts(vault, allocation_violations, bad_debt_alerts) total_risk_score = round(total_risk_score, 2) logger.info("V2 vault %s on %s — total risk score %.2f", vault.name, vault.chain.name, total_risk_score) - _alert_vault_risk(vault, vault_total_assets_usd, total_risk_score) - - -def _aggregate_expected_assets(market_adapters: List[AdapterInfo]) -> dict[str, int]: - """Sum expected assets by market across every adapter.""" - underlying_per_market: dict[str, int] = {} - for adapter in market_adapters: - for market_id, expected_assets in zip(adapter.market_ids, adapter.expected_supply_assets, strict=True): - market_id = market_id.lower() - underlying_per_market[market_id] = underlying_per_market.get(market_id, 0) + int(expected_assets) - return underlying_per_market + _alert_vault_risk(vault, total_risk_score) def _assess_market( vault: V2Vault, market_id: str, - expected_assets: int, + allocation_usd: float, metrics: Dict[str, MarketMetrics], - vault_total_assets_usd: float, ) -> Optional[MarketAssessment]: """Calculate risk and alert details for one market exposure.""" market = metrics.get(market_id) @@ -321,12 +245,7 @@ def _assess_market( logger.info("No GraphQL data for market %s; skipping", market_id) return None - allocation_usd = _allocation_to_usd(market, expected_assets) - if allocation_usd is None: - logger.info("Cannot derive USD allocation for market %s; skipping", market_id) - return None - - allocation_ratio = min(allocation_usd / vault_total_assets_usd, 1.0) + allocation_ratio = min(allocation_usd / vault.total_assets_usd, 1.0) risk_level = get_market_risk_level(market_id, vault.chain) assessment = assess_exposure( allocation_ratio, @@ -374,7 +293,7 @@ def _send_market_assessment_alerts( send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) -def _alert_vault_risk(vault: V2Vault, vault_total_assets_usd: float, total_risk_score: float) -> None: +def _alert_vault_risk(vault: V2Vault, total_risk_score: float) -> None: """Alert when a Vault V2 weighted risk score exceeds its tier limit.""" max_risk = MAX_RISK_THRESHOLDS[vault.risk_level] if total_risk_score <= max_risk: @@ -384,26 +303,11 @@ def _alert_vault_risk(vault: V2Vault, vault_total_assets_usd: float, total_risk_ message = ( f"⚠️ V2 high risk in [{vault.name}]({vault_url}) (risk {vault.risk_level}) on {vault.chain.name}\n" f"🔢 Risk level: {total_risk_score:.2f} (max: {max_risk:.2f})\n" - f"🔢 Total assets: ${vault_total_assets_usd:,.2f}" + f"🔢 Total assets: ${vault.total_assets_usd:,.2f}" ) send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) -def _allocation_to_usd(market: MarketMetrics, expected_assets: int) -> Optional[float]: - """Convert ``expectedSupplyAssets`` (underlying units) to USD via market state ratio. - - Uses ``supplyAssetsUsd / supplyAssets`` when both are non-zero, falling back to - the borrow side ratio. Returns None when neither side carries enough state to - derive a price (typically a freshly created or empty market). - """ - state = market.state - if state.supply_assets > 0 and state.supply_assets_usd > 0: - return expected_assets * state.supply_assets_usd / state.supply_assets - if state.borrow_assets > 0 and state.borrow_assets_usd > 0: - return expected_assets * state.borrow_assets_usd / state.borrow_assets - return None - - def _market_label(market: MarketMetrics, market_id: str, chain: Chain) -> str: """Render a clickable Markdown label for a Morpho Blue market.""" loan = market.loan_asset.symbol or "?" @@ -411,50 +315,7 @@ def _market_label(market: MarketMetrics, market_id: str, chain: Chain) -> str: return f"[{coll}/{loan}]({get_market_url(market_id, chain)})" -def _adapter_allocation_ratio(vault: V2Vault, adapter_address: str) -> Optional[float]: - """Return an adapter's share of vault TVL from GraphQL ``assetsUsd`` data.""" - if vault.total_assets_usd <= 0: - return None - addr_lc = adapter_address.lower() - for item in vault.graphql_adapters: - if (item.get("address") or "").lower() == addr_lc: - assets_usd = float(item.get("assetsUsd") or 0) - return min(assets_usd / vault.total_assets_usd, 1.0) - return None - - -def analyze_vault_adapter(vault: V2Vault, adapter: AdapterInfo) -> None: - """Sanity-check that a wrapped v1 vault is already monitored by markets.py.""" - if adapter.wrapped_v1_vault is None: - return - - allocation_ratio = _adapter_allocation_ratio(vault, adapter.address) - if allocation_ratio is not None and allocation_ratio < MIN_VAULT_ADAPTER_ALLOCATION_RATIO: - return - monitored = {config.address.lower() for config in VAULTS_V1_BY_CHAIN.get(vault.chain, ())} - wrapped_lc = adapter.wrapped_v1_vault.lower() - if wrapped_lc in monitored: - return - - # Dedup across hourly runs — alert once per (parent vault, wrapped v1 vault). - cache_key = morpho_key(vault.address.lower(), wrapped_lc, VAULT_ADAPTER_SEEN_TYPE) - if str(get_last_value_for_key_from_file(morpho_filename, cache_key)) == "1": - return - - vault_url = get_vault_url(vault.address, vault.chain) - send_alert( - Alert( - AlertSeverity.LOW, - f"ℹ️ V2 [{vault.name}]({vault_url}) on {vault.chain.name} wraps unmonitored v1 vault " - f"`{adapter.wrapped_v1_vault}` — consider adding it to " - f"morpho/config.py:VAULTS_V1_BY_CHAIN.", - PROTOCOL, - ) - ) - write_last_value_to_file(morpho_filename, cache_key, 1) - - -def analyze_v2_vault(client: Web3Client, vault: V2Vault) -> None: +def analyze_v2_vault(vault: V2Vault, metrics: Dict[str, MarketMetrics]) -> None: """Run all v2 monitoring checks for a single vault.""" if vault.total_assets_usd < MIN_VAULT_ASSETS_USD: logger.info( @@ -466,20 +327,7 @@ def analyze_v2_vault(client: Web3Client, vault: V2Vault) -> None: ) return - adapter_addresses = list_adapters(client, vault.address) - adapters = [classify_adapter(client, addr) for addr in adapter_addresses] - - market_adapters: List[AdapterInfo] = [] - for adapter in adapters: - if adapter.kind == ADAPTER_KIND_MARKET: - market_adapters.append(adapter) - elif adapter.kind == ADAPTER_KIND_VAULT: - analyze_vault_adapter(vault, adapter) - else: - raise MorphoV2MonitoringError(f"Unsupported adapter kind {adapter.kind} for {adapter.address}") - - if market_adapters: - score_market_allocations(vault, market_adapters, vault.total_assets_usd) + score_market_allocations(vault, metrics) if not is_collateral_vault(vault.address, vault.chain, version=2): check_low_liquidity(vault) @@ -520,10 +368,18 @@ def main() -> None: for chain, vaults in vaults_by_chain.items(): if not vaults: continue - client = ChainManager.get_client(chain) + + market_ids = sorted({market_id for vault in vaults for market_id in vault.market_allocations_usd}) + try: + metrics = fetch_market_metrics(market_ids, chain) + except Exception as e: + logger.exception("Failed to fetch market metrics on %s", chain.name) + failures.append(f"markets on {chain.name}: {type(e).__name__}: {e}") + continue + for vault in vaults: try: - analyze_v2_vault(client, vault) + analyze_v2_vault(vault, metrics) except Exception as e: logger.exception("Failed to analyze V2 vault %s on %s", vault.address, chain.name) failures.append(f"{vault.name} on {chain.name}: {type(e).__name__}: {e}") diff --git a/tests/test_morpho_markets.py b/tests/test_morpho_markets.py index 98d6274..c6ba93e 100644 --- a/tests/test_morpho_markets.py +++ b/tests/test_morpho_markets.py @@ -23,16 +23,32 @@ get_yv_collateral_liquidity_by_asset, ) from protocols.morpho.markets_v2 import ( - AdapterInfo, V2Vault, + _parse_market_allocations, check_low_liquidity, discover_v2_vaults_by_chain, - list_adapters, + main, score_market_allocations, ) from utils.chains import Chain +def _sample_metrics(market_id: str, asset_address: str = "0x" + "22" * 20) -> MarketMetrics: + return MarketMetrics( + market_id=market_id, + loan_asset=Asset(address=asset_address, symbol="USDC"), + collateral_asset=Asset(address="0x" + "44" * 20, symbol="WETH"), + state=MarketState( + utilization=0.5, + borrow_assets=100, + supply_assets=100, + borrow_assets_usd=100, + supply_assets_usd=100, + ), + bad_debt=BadDebt(underlying=0, usd=0), + ) + + class TestMorphoV2Configuration(unittest.TestCase): def test_katana_collateral_strategy_vaults_are_monitored(self) -> None: configured = {vault.address.lower() for vault in VAULTS_V2_BY_CHAIN[Chain.KATANA]} @@ -65,14 +81,56 @@ def test_discovery_fails_if_api_omits_configured_vaults(self) -> None: ): discover_v2_vaults_by_chain() - def test_adapter_read_failure_is_not_silently_treated_as_empty(self) -> None: - client = MagicMock() - client.get_contract.return_value.functions.adaptersLength.return_value.call.side_effect = RuntimeError( - "RPC unavailable" - ) + def test_discovery_rejects_non_market_adapters(self) -> None: + item = { + "adapters": { + "items": [ + { + "address": "0x" + "33" * 20, + "type": "MetaMorpho", + "assetsUsd": 100_000, + } + ] + } + } - with self.assertRaisesRegex(MorphoV2MonitoringError, "Failed to read adaptersLength"): - list_adapters(client, "0x" + "11" * 20) + with self.assertRaisesRegex(MorphoV2MonitoringError, "unsupported adapter type"): + _parse_market_allocations(item, "Example", Chain.MAINNET) + + def test_parse_market_allocations_aggregates_morpho_market_v1_positions(self) -> None: + market_a = "0x" + "aa" * 32 + market_b = "0x" + "bb" * 32 + item = { + "adapters": { + "items": [ + { + "address": "0x" + "33" * 20, + "type": "MorphoMarketV1", + "positions": { + "items": [ + { + "market": {"marketId": market_a}, + "state": {"supplyAssetsUsd": 10_000}, + }, + { + "market": {"marketId": market_a}, + "state": {"supplyAssetsUsd": 5_000}, + }, + { + "market": {"marketId": market_b}, + "state": {"supplyAssetsUsd": 0}, + }, + ] + }, + } + ] + } + } + + self.assertEqual( + _parse_market_allocations(item, "Example", Chain.MAINNET), + {market_a: 15_000.0}, + ) def test_market_scoring_consolidates_all_v2_risk_alerts(self) -> None: market_id = "0x" + "ab" * 32 @@ -82,35 +140,28 @@ def test_market_scoring_consolidates_all_v2_risk_alerts(self) -> None: chain=Chain.MAINNET, asset_address="0x" + "22" * 20, asset_symbol="USDC", - curator="", - owner="", risk_level=1, + total_assets_usd=100, + market_allocations_usd={market_id: 80}, ) - adapter = AdapterInfo( - address="0x" + "33" * 20, - kind="MorphoMarketV1AdapterV2", - market_ids=[market_id], - expected_supply_assets=[80], - ) - metrics = MarketMetrics( - market_id=market_id, - loan_asset=Asset(address=vault.asset_address, symbol="USDC"), - collateral_asset=Asset(address="0x" + "44" * 20, symbol="WETH"), - state=MarketState( - utilization=0.5, - borrow_assets=100, - supply_assets=100, - borrow_assets_usd=100, - supply_assets_usd=100, - ), - bad_debt=BadDebt(underlying=1, usd=1), - ) + metrics = { + market_id: MarketMetrics( + market_id=market_id, + loan_asset=Asset(address=vault.asset_address, symbol="USDC"), + collateral_asset=Asset(address="0x" + "44" * 20, symbol="WETH"), + state=MarketState( + utilization=0.5, + borrow_assets=100, + supply_assets=100, + borrow_assets_usd=100, + supply_assets_usd=100, + ), + bad_debt=BadDebt(underlying=1, usd=1), + ) + } - with ( - patch("protocols.morpho.markets_v2.fetch_market_metrics", return_value={market_id: metrics}), - patch("protocols.morpho.markets_v2.send_alert") as send, - ): - score_market_allocations(vault, [adapter], 100) + with patch("protocols.morpho.markets_v2.send_alert") as send: + score_market_allocations(vault, metrics) messages = [call.args[0].message for call in send.call_args_list] self.assertEqual(len(messages), 3) @@ -118,6 +169,67 @@ def test_market_scoring_consolidates_all_v2_risk_alerts(self) -> None: self.assertTrue(any("V2 bad debt" in message for message in messages)) self.assertTrue(any("V2 high risk" in message for message in messages)) + def test_main_batches_metrics_per_chain_and_continues_after_failure(self) -> None: + market_a = "0x" + "aa" * 32 + market_b = "0x" + "bb" * 32 + market_c = "0x" + "cc" * 32 + vault_mainnet_a = V2Vault( + name="Mainnet A", + address="0x" + "11" * 20, + chain=Chain.MAINNET, + asset_address="0x" + "22" * 20, + asset_symbol="USDC", + risk_level=1, + total_assets_usd=100_000, + market_allocations_usd={market_a: 40_000, market_b: 60_000}, + ) + vault_mainnet_b = V2Vault( + name="Mainnet B", + address="0x" + "33" * 20, + chain=Chain.MAINNET, + asset_address="0x" + "22" * 20, + asset_symbol="USDC", + risk_level=1, + total_assets_usd=50_000, + market_allocations_usd={market_b: 50_000}, + ) + vault_base = V2Vault( + name="Base Vault", + address="0x" + "55" * 20, + chain=Chain.BASE, + asset_address="0x" + "66" * 20, + asset_symbol="USDC", + risk_level=1, + total_assets_usd=100_000, + market_allocations_usd={market_c: 100_000}, + ) + base_metrics = {market_c: _sample_metrics(market_c, vault_base.asset_address)} + + def fetch_side_effect(market_ids: list[str], chain: Chain) -> dict[str, MarketMetrics]: + if chain == Chain.MAINNET: + self.assertEqual(market_ids, sorted({market_a, market_b})) + raise MorphoV2MonitoringError("mainnet metrics unavailable") + self.assertEqual(chain, Chain.BASE) + self.assertEqual(market_ids, [market_c]) + return base_metrics + + with ( + patch( + "protocols.morpho.markets_v2.discover_v2_vaults_by_chain", + return_value={ + Chain.MAINNET: [vault_mainnet_a, vault_mainnet_b], + Chain.BASE: [vault_base], + }, + ), + patch("protocols.morpho.markets_v2.fetch_market_metrics", side_effect=fetch_side_effect) as fetch, + patch("protocols.morpho.markets_v2.analyze_v2_vault") as analyze, + self.assertRaisesRegex(MorphoV2MonitoringError, "markets on MAINNET"), + ): + main() + + self.assertEqual(fetch.call_count, 2) + analyze.assert_called_once_with(vault_base, base_metrics) + class TestMorphoCollateralLiquidity(unittest.TestCase): def test_collateral_curve_can_resolve_stable_price_shock(self) -> None: @@ -239,8 +351,6 @@ def test_v2_low_liquidity_alert_uses_graphql_liquidity(self) -> None: chain=Chain.MAINNET, asset_address="0x" + "22" * 20, asset_symbol="USDC", - curator="", - owner="", risk_level=1, total_assets_usd=100_000, liquidity_usd=500,