diff --git a/monitoring.yaml b/monitoring.yaml index f34de64..c387cf3 100644 --- a/monitoring.yaml +++ b/monitoring.yaml @@ -278,13 +278,13 @@ protocols: - name: "Vault Risk Levels" description: "Weighted total risk score vs vault threshold" - name: "Low Liquidity" - description: "Vault liquidity <1% of assets; YV-collateral unwind liquidity vs collateral at risk" + description: "V1/V2 vault liquidity <1%; combined v1/v2 YV-collateral unwind liquidity vs collateral at risk" - name: "V1 Governance" description: "Pending supply caps, market removals, timelock changes, guardian changes" - name: "V2 Governance" description: "Pending timelocked operations, owner/curator changes, sentinel/allocator/adapter changes" - - name: "V2 Unmonitored Vault Wrappers" - description: "V2 vault wrapping a v1 vault not in markets.py:VAULTS_BY_CHAIN" + - name: "V2 Wrapped V1 Coverage" + description: "V2 vault wrapping a v1 vault not in config.py:VAULTS_V1_BY_CHAIN" - name: "Safe Multisig" description: "Morpho eUSDe predeposit vault owner Safe queue" diff --git a/protocols/morpho/README.md b/protocols/morpho/README.md index ddd3c9f..5141dfb 100644 --- a/protocols/morpho/README.md +++ b/protocols/morpho/README.md @@ -15,16 +15,15 @@ The script checks if there are any new values pending in the timelock for a give ### How to Add a New Vault -Add the vault address to either the `MAINNET_VAULTS` or `BASE_VAULTS` variable in [governance.py#L21](./governance.py#L21) to monitor governance changes. +Add a `VaultConfig` to `VAULTS_V1_BY_CHAIN` in [config.py](./config.py). Every configured V1 vault receives both market and governance monitoring. ## Vaults & Markets Morpho Vaults consist of multiple markets, each defining key parameters such as LTV, interest rate models, and oracle data. -Market monitoring is configured via the vault definitions in [markets.py#L13](./markets.py#L13). The script fetches all markets for each vault and checks the following metrics: +Market monitoring is configured through [config.py](./config.py), while shared market and liquidity policy lives in [risk.py](./risk.py). The script fetches all markets for each vault and checks the following metrics: - **Bad Debt Ratio:** If the bad debt ratio exceeds 0.5% of total borrowed assets, a Telegram message is sent. -- **Utilization Ratio:** If the utilization ratio exceeds 95%, a Telegram message is sent. - **Vault Risk Level:** If the computed risk level of a vault exceeds its maximum threshold, a Telegram message is sent. - **Market Allocation Ratio:** If any market's allocation ratio exceeds its risk-adjusted threshold, a Telegram message is sent. @@ -32,42 +31,42 @@ Additional insights on Morpho vault risks are available at [Llama Risk blog](htt ### Risk Levels -The overall risk level of a Morpho Vault is determined by the risk levels of its markets. For more details, refer to the comments in [markets.py#L36](./markets.py#L36). Markets and vaults are categorized by their risk level and blockchain, with Level 1 representing the safest configuration. +The overall risk level of a Morpho Vault is determined by the risk levels of its markets. Markets and thresholds are defined in [risk.py](./risk.py); vault scores are defined in [config.py](./config.py). Both are mutable operating configuration and are intentionally not fixed by tests. Level 1 represents the safest configuration. ### Oracle validation -When adding or checking market rows in [markets.py](./markets.py), use [morpho-oracle-validation.md](./morpho-oracle-validation.md): resolve `uniqueKey` and `oracle.address` via the Morpho GraphQL API, validate feeds on-chain (typically `MorphoChainlinkOracleV2` getters and `description()`), then classify feeds (Chainlink, RedStone, Chronicle, API3, or unknown) using on-chain hints plus official listings — [Chainlink](https://docs.chain.link/data-feeds/price-feeds/addresses) / [data.chain.link](https://data.chain.link), [RedStone](https://docs.redstone.finance/), [Chronicle Oracles](https://chroniclelabs.org/dashboard/oracles), [Api3 Market](https://market.api3.org/) (see §4 in that doc). +When adding or checking market rows in [risk.py](./risk.py), use [morpho-oracle-validation.md](./morpho-oracle-validation.md): resolve `uniqueKey` and `oracle.address` via the Morpho GraphQL API, validate feeds on-chain (typically `MorphoChainlinkOracleV2` getters and `description()`), then classify feeds (Chainlink, RedStone, Chronicle, API3, or unknown) using on-chain hints plus official listings — [Chainlink](https://docs.chain.link/data-feeds/price-feeds/addresses) / [data.chain.link](https://data.chain.link), [RedStone](https://docs.redstone.finance/), [Chronicle Oracles](https://chroniclelabs.org/dashboard/oracles), [Api3 Market](https://market.api3.org/) (see §4 in that doc). ### How to Add a New Vault -To monitor a new Morpho vault, add its address to the `VAULTS_BY_CHAIN` variable in [markets.py#L13](./markets.py#L13). This ensures that both the vault's overall metrics and its individual markets are monitored. +To monitor a new Morpho vault, add a `VaultConfig` to `VAULTS_V1_BY_CHAIN` or `VAULTS_V2_BY_CHAIN` in [config.py](./config.py). This is the single source used by market and governance monitoring. -**For YV Collateral Vaults:** If Morpho vault is using Yearn V3 Vault (YV collateral vault) as collateral, additional configuration is needed. Add all Morpho Vaults that are used as strategies in Yearn V3 Vault to `VAULTS_WITH_YV_COLLATERAL_BY_ASSET` mapping, organized by chain and underlying asset address. This enables combined liquidity monitoring for all vaults with the same asset. +**For YV Collateral Vaults:** Set `collateral_asset` on each V1 or V2 `VaultConfig` used by the Yearn strategy. Vaults with the same underlying asset are grouped automatically for combined liquidity monitoring. ### Bad Debt -Bad debt is fetched from the Morpho GraphQL API. Each market is checked for bad debt; if any market exhibits bad debt, a Telegram message is sent. The script runs hourly via the [monitoring runner](../automation/jobs.yaml). The monitoring logic is implemented in [markets.py#L166](./markets.py#L166). +Bad debt is fetched from the Morpho GraphQL API. Each market is checked for bad debt; if any market exhibits bad debt, a Telegram message is sent. The script runs hourly via the [monitoring runner](../../automation/jobs.yaml). The monitoring logic is implemented in [markets.py](./markets.py). -### Utilization & Liquidity +### Liquidity -The utilization ratio for each market is calculated as the ratio of borrowed assets to total collateral assets. If this ratio exceeds 95%, a Telegram message is sent. The script runs hourly via the [monitoring runner](../automation/jobs.yaml), and the monitoring logic is defined in [markets.py#L263](./markets.py#L263). Note that liquidity is the inverse of utilization—high utilization implies low liquidity (e.g., 95% utilization corresponds to 5% liquidity). +The standard check alerts when immediately withdrawable vault liquidity falls below the shared threshold in [risk.py](./risk.py). YV-collateral strategy vaults use the market-aware coverage check below instead of the standard percentage threshold. #### YV Collateral Vault Liquidity Monitoring For vaults that are used as collateral in Yearn v3 strategies (YV collateral vaults), the system implements market-aware unwind liquidity monitoring. Instead of checking each vault individually, vaults with the same underlying asset are grouped together and their withdrawable liquidity is aggregated. -**Configuration:** YV collateral vaults are defined in the `VAULTS_WITH_YV_COLLATERAL_BY_ASSET` mapping in [markets.py](./markets.py), organized by chain and asset address. Watched direct YV-collateral markets are explicitly defined in `YV_COLLATERAL_MARKETS_BY_ASSET`, using the same underlying asset addresses. +**Configuration:** YV collateral strategy vaults set `collateral_asset` in [config.py](./config.py). Keep both generations configured while liquidity migrates from V1 to V2. Watched direct YV-collateral markets are defined in `YV_COLLATERAL_MARKETS_BY_ASSET` in the same file. **Thresholds:** -- **Regular vaults:** Defined in the `LIQUIDITY_THRESHOLD` variable in [markets.py#L25](./markets.py#L25). -- **YV collateral vaults:** Require enough combined withdrawable liquidity to cover collateral at risk in direct YV-collateral Morpho markets, plus a liquidation buffer (`YV_COLLATERAL_*` constants in [markets.py#L26](./markets.py#L26)). Price shock is selected from market LLTV: 5% for LLTV >= 86%, 15% for LLTV <= 77%, otherwise 10%. +- **Regular vaults:** Defined by `LIQUIDITY_THRESHOLD` in [risk.py](./risk.py) and shared by V1 and V2. +- **YV collateral vaults:** Require enough combined withdrawable liquidity to cover collateral at risk in direct YV-collateral Morpho markets, plus a liquidation buffer (`YV_COLLATERAL_*` constants in [markets.py](./markets.py)). Price shock is selected from market LLTV: 2% for LLTV >= 86%, 15% for LLTV <= 77%, otherwise 10%. **Logic:** For each asset group (e.g., all USDC vaults at one chain), the system: -1. Calculates combined total assets across all vaults with the same asset -2. Calculates combined available liquidity across all vaults with the same asset -3. Finds configured direct Yearn vault collateral markets such as `yvvbUSDC/vbUSDT` +1. Calculates combined total assets across all configured v1 and v2 strategy vaults with the same asset +2. Calculates combined immediately withdrawable liquidity across those vaults. Shared Morpho market liquidity is capped once per market so v1 and v2 vaults cannot double-count the same cash. For v2 this uses the API's `liquidityUsd` value (idle assets plus the selected liquidity adapter) and conservatively excludes force-deallocatable liquidity +3. Queries configured direct Yearn vault collateral markets such as `yvvbUSDC/vbUSDT` independently of vault allocations, so a v1 allocation removal cannot disable the check 4. Fetches Morpho collateral-at-risk data at the configured adverse price shock 5. Sums collateral at risk per underlying asset group 6. Sends alerts only if combined withdrawable liquidity is below total collateral at risk plus buffer @@ -88,15 +87,17 @@ Where: - **Allocation:** The percentage of the vault's assets allocated to that market. - **Total Risk Level:** The sum of the weighted risks across all markets. -This computed risk level is compared against predefined maximum thresholds defined in [markets.py#L134](./markets.py#L134): +This computed risk level is compared against the mutable `MAX_RISK_THRESHOLDS` configuration in [risk.py](./risk.py). -- **Risk Level 1:** Maximum threshold of 1.15 -- **Risk Level 2:** Maximum threshold of 2.30 -- **Risk Level 3:** Maximum threshold of 3.45 -- **Risk Level 4:** Maximum threshold of 4.60 -- **Risk Level 5:** Maximum threshold of 5.00 +The current maximum total-risk thresholds are: -Each tier is `level × 1.15` (capped at 5.00), so a Risk-1 vault may hold up to ~15% of its assets in Risk-2 markets before an alert triggers. +- **Risk Level 1:** 1.15 +- **Risk Level 2:** 2.30 +- **Risk Level 3:** 3.45 +- **Risk Level 4:** 4.60 +- **Risk Level 5:** 5.00 + +For example, a Risk Level 2 vault alerts when its weighted total risk exceeds 2.30. These values are a snapshot for readability; [risk.py](./risk.py) remains the source of truth and may be updated as risk assessments change. If a vault's total risk level exceeds its threshold, an alert is triggered via a Telegram message. @@ -104,23 +105,24 @@ If a vault's total risk level exceeds its threshold, an alert is triggered via a The system monitors each market's allocation within a vault to ensure it does not exceed its risk-adjusted threshold. Each market has a maximum allocation threshold based on its inherent risk tier and the vault's overall risk level. -The base allocation limits by risk tier (as defined in [markets.py#L125](./markets.py#L125)) are: +The mutable base allocation limits and vault-level adjustment are defined by `ALLOCATION_TIERS` and `get_market_allocation_threshold` in [risk.py](./risk.py). Higher-risk vault configurations can accept more exposure to a given market tier. + +The current base limits are: -- **Risk Level 1:** 100% -- **Risk Level 2:** 30% -- **Risk Level 3:** 10% -- **Risk Level 4:** 5% -- **Risk Level 5:** 1% +- **Risk Level 1 market:** 101% configured, effectively allowing a full allocation +- **Risk Level 2 market:** 30% +- **Risk Level 3 market:** 10% +- **Risk Level 4 market:** 5% +- **Risk Level 5 or unknown market:** 1% -These limits apply to vaults with a risk level of 1. For vaults with higher risk levels, the thresholds become more permissive. The adjustment is calculated in the [get_market_allocation_threshold](./markets.py#L143) function. +The vault risk level reduces the market tier used to select the limit by one step for each level above Risk Level 1, with a floor of Risk Level 1. For example: -Examples: +- A Risk Level 1 vault may allocate up to 30% to a Risk Level 2 market. +- A Risk Level 2 vault may allocate fully to a Risk Level 2 market because its adjusted market tier is Risk Level 1. +- A Risk Level 2 vault may allocate up to 10% to a Risk Level 4 market. +- A Risk Level 3 vault may allocate up to 30% to a Risk Level 4 market. -- A Risk-1 vault accepts up to 30% of its total assets in a Risk-2 market. -- A Risk-2 vault accepts up to 80% of its total assets in a Risk-2 market. -- A Risk-3 vault accepts up to 100% of its total assets in a Risk-2 market. -- A Risk-2 vault accepts up to 10% of its total assets in a Risk-4 market. -- A Risk-3 vault accepts up to 30% of its total assets in a Risk-4 market. +Allocation ratios are capped at 100% by the monitor, so the configured 101% Risk Level 1 limit acts as a full-allocation allowance. As with the total-risk thresholds, [risk.py](./risk.py) is the source of truth for these mutable values. The system monitors the allocation ratio for each market hourly: @@ -135,12 +137,12 @@ 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 runs the existing v1 risk-tier scoring against the underlying Morpho Blue markets when the vault uses `MorphoMarketV1AdapterV2`. For `MorphoVaultV1Adapter` (today's common case) the wrapped v1 vault keeps receiving its full v1 analysis via `markets.py`; we only flag the case where v2 introduces a new wrapped v1 vault that operators should add to `VAULTS_BY_CHAIN`. +- [`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`. - [`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 -Monitored v2 vaults live in [`VAULTS_V2_BY_CHAIN`](./markets_v2.py) — same shape as the v1 [`VAULTS_BY_CHAIN`](./markets.py#L29), one `[name, address, risk_level]` row per vault. The initial list is sourced from [Yearn's curator page on Morpho](https://app.morpho.org/curator/yearn?v2=true) (filtered via GraphQL by Yearn's curator addresses) and is kept manually so a third-party squatting on the name doesn't get monitored as a Yearn vault. +Monitored V1 and V2 vaults live in [`config.py`](./config.py) as typed `VaultConfig` rows. The V2 list is sourced from [Yearn's curator page on Morpho](https://app.morpho.org/curator/yearn?v2=true) and remains explicit so a third party using a similar name is not monitored automatically. To add a new v2 vault, append a row to the chain's list and pick a risk tier (1–5). @@ -164,9 +166,9 @@ Both scripts share `MORPHO_FILENAME` (default `cache-id.txt`). New key types add ```bash # Hourly (allocation + risk) -python morpho/markets_v2.py +python protocols/morpho/markets_v2.py # Daily (timelocks + role changes) -python morpho/governance_v2.py +python protocols/morpho/governance_v2.py ``` Set `MORPHO_FILENAME=/tmp/morpho-cache.txt` to use an isolated cache while testing. diff --git a/protocols/morpho/_shared.py b/protocols/morpho/_shared.py index 54515a4..911c258 100644 --- a/protocols/morpho/_shared.py +++ b/protocols/morpho/_shared.py @@ -1,7 +1,9 @@ """Shared helpers used by both v1 and v2 Morpho monitors.""" from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Iterable, List, Optional + +import requests from utils.chains import Chain from utils.http_client import request_with_retry @@ -9,52 +11,63 @@ API_URL = "https://api.morpho.org/graphql" MORPHO_URL = "https://app.morpho.org" +PROTOCOL = "morpho" logger = get_logger("morpho.shared") -# Yearn-curated Morpho V2 vaults — sourced from -# https://app.morpho.org/curator/yearn?v2=true (filtered via GraphQL by -# Yearn's curator addresses). Imported by both ``markets_v2.py`` and -# ``governance_v2.py``. To add a new vault, append a -# ``[name, address, risk_level]`` row to the appropriate chain. Risk levels -# follow the same 1–5 scheme as v1 ``markets.py:VAULTS_BY_CHAIN``. -VAULTS_V2_BY_CHAIN: Dict[Chain, List[List[Any]]] = { - Chain.MAINNET: [ - # name, address, risk level - ["Yearn USDC", "0xaA8d9E2aBa210639cE6C7cE21385e7c673ACa6f3", 1], - ["Yearn OG WETH V2", "0xbe518068EB6135117207256F8C9aFf81B4382DB1", 1], - [ - "Yearn OG USDC", - "0xB885F6d448dA7E2C642Ec31190B629E40E87B069", - 2, - ], - ["Sentora RLUSD Main", "0x6dC58a0FdfC8D694e571DC59B9A52EEEa780E6bf", 2], - ["Sentora PaypalUSD Main", "0xb576765fB15505433aF24FEe2c0325895C559FB2", 2], - ], - Chain.BASE: [ - ["Yearn OG USDC V2", "0xe7D0DBE3493830e2Ab62619211A2BfF0Fc60dB42", 2], - ["Yearn OG WETH V2", "0x2EfD54529329AD364B8Df988CE3BAb5Ff256ab3E", 2], - ["OUSD Vault V2", "0x2Ba14b2e1E7D2189D3550b708DFCA01f899f33c1", 2], - ], - Chain.KATANA: [ - # ["Yearn OG USDC", "0xca44cbe1FB03691d43d2d93AA460e2fCB03878fE", 1], - # ["Yearn OG USDT", "0x4284d4F9f4d61eA57B8F0943547c7C19C5B9B249", 1], - # ["Yearn OG WBTC", "0x22c01834e1A261F8BebCa7D7B459db2F389785FF", 1], - # ["Yearn OG ETH", "0x5920A6FC553af799542EDA628AdfCc9eA52e141C", 1], - ["Yearn KAT", "0x9b1aE9548E4B46cEB6650f6CEc702bAf5CF2b8CC", 1], - ["Yearn Degen USDC", "0xA2d38c8A3D810EBcF4C2075821c5eC8F976bb692", 3], - ], -} - -SUPPORTED_CHAINS: List[Chain] = list(VAULTS_V2_BY_CHAIN.keys()) +class MorphoMonitoringError(RuntimeError): + """Raised when Morpho monitoring cannot produce a complete result.""" + + +class MorphoV2MonitoringError(MorphoMonitoringError): + """Raised when configured Morpho Vault V2 monitoring is incomplete.""" + + +def execute_graphql( + query: str, + variables: dict[str, Any], + context: str, + *, + error_type: type[MorphoMonitoringError] = MorphoMonitoringError, +) -> dict[str, Any]: + """Execute a strict Morpho GraphQL request and return its data object.""" + try: + response = request_with_retry("post", API_URL, json={"query": query, "variables": variables}) + except requests.RequestException as exc: + raise error_type(f"Failed to fetch {context}: {exc}") from exc + + try: + payload = response.json() + except ValueError as exc: + raise error_type(f"Morpho returned invalid JSON while fetching {context}") from exc + + if payload.get("errors"): + raise error_type(f"Morpho GraphQL errors fetching {context}: {payload['errors']}") + data = payload.get("data") + if not isinstance(data, dict): + raise error_type(f"Morpho GraphQL returned no data while fetching {context}") + return data + + +def require_configured_keys( + expected: Iterable[str], + found: Iterable[str], + context: str, + *, + error_type: type[MorphoMonitoringError] = MorphoMonitoringError, +) -> None: + """Raise when a GraphQL collection omits any configured key.""" + missing = sorted({key.lower() for key in expected} - {key.lower() for key in found}) + if missing: + raise error_type(f"Morpho API omitted configured {context}: " + ", ".join(missing)) def get_chain_name(chain: Chain) -> str: """Return the chain segment used in Morpho frontend URLs.""" if chain == Chain.MAINNET: return "ethereum" - return chain.name.lower() + return str(chain.name).lower() def get_market_url(market_id: str, chain: Chain) -> str: @@ -67,6 +80,26 @@ def get_vault_url(vault_address: str, chain: Chain) -> str: return f"{MORPHO_URL}/{get_chain_name(chain)}/vault/{vault_address}" +def format_low_liquidity_message( + vault_name: str, + vault_url: str, + chain: Chain, + total_assets_usd: float, + liquidity_usd: float, + threshold: float, + *, + version_label: str = "", +) -> str: + """Format the common V1/V2 low-liquidity alert body.""" + liquidity_ratio = liquidity_usd / total_assets_usd + prefix = f"{version_label} " if version_label else "" + return ( + f"⚠️ Low liquidity in {prefix}[{vault_name}]({vault_url}) on {chain.name}\n" + f"💰 Liquidity: ${liquidity_usd:,.2f} ({liquidity_ratio:.1%} of ${total_assets_usd:,.2f})\n" + f"📊 Min threshold: {threshold:.1%}\n" + ) + + def fetch_market_metadata(market_id: str, chain: Chain) -> dict[str, Any] | None: """Fetch symbols and loan decimals for a Morpho market. @@ -75,24 +108,26 @@ def fetch_market_metadata(market_id: str, chain: Chain) -> dict[str, Any] | None query = """ query GetMarket($marketId: String!, $chainId: Int!) { marketById(marketId: $marketId, chainId: $chainId) { + lltv loanAsset { symbol, decimals } collateralAsset { symbol } } } """ try: - response = request_with_retry( - "post", - API_URL, - json={"query": query, "variables": {"marketId": market_id, "chainId": chain.chain_id}}, + data = execute_graphql( + query, + {"marketId": market_id, "chainId": chain.chain_id}, + f"market metadata for {market_id} on {chain.name}", ) - market = response.json()["data"]["marketById"] + market = data["marketById"] collateral_symbol = market["collateralAsset"]["symbol"] if market.get("collateralAsset") else "idle" loan_asset = market["loanAsset"] return { "name": f"{collateral_symbol}/{loan_asset['symbol']}", "loan_symbol": loan_asset["symbol"], "loan_decimals": int(loan_asset["decimals"]), + "lltv": int(market.get("lltv") or 0), } except Exception as e: logger.warning("Failed to fetch market metadata for %s: %s", market_id, e) @@ -183,7 +218,7 @@ def fetch_market_metrics(market_ids: List[str], chain: Chain) -> Dict[str, Marke """Fetch state + bad debt for a batch of market IDs. Returns a dict keyed by lowercase market_id mapping to a ``MarketMetrics`` - dataclass. Empty dict on GraphQL error or empty input. + dataclass. Raises if Morpho returns errors or omits a requested market. """ if not market_ids: return {} @@ -208,12 +243,15 @@ def fetch_market_metrics(market_ids: List[str], chain: Chain) -> Dict[str, Marke } """ keys = [mid.lower() for mid in market_ids] - response = request_with_retry( - "post", API_URL, json={"query": query, "variables": {"keys": keys, "chainId": chain.chain_id}} + data = execute_graphql( + query, + {"keys": keys, "chainId": chain.chain_id}, + f"market metrics on {chain.name}", + error_type=MorphoV2MonitoringError, ) - payload = response.json() - if "errors" in payload: - logger.warning("GraphQL error fetching market metrics: %s", payload["errors"]) - return {} - items = payload.get("data", {}).get("markets", {}).get("items", []) or [] - return {item["marketId"].lower(): _parse_market_metrics(item) for item in items} + items = data.get("markets", {}).get("items", []) or [] + metrics = {item["marketId"].lower(): _parse_market_metrics(item) for item in items} + missing_market_ids = sorted(set(keys) - set(metrics)) + if missing_market_ids: + raise MorphoV2MonitoringError("Morpho API omitted configured market IDs: " + ", ".join(missing_market_ids)) + return metrics diff --git a/protocols/morpho/config.py b/protocols/morpho/config.py new file mode 100644 index 0000000..645f344 --- /dev/null +++ b/protocols/morpho/config.py @@ -0,0 +1,169 @@ +"""Static Morpho vault and collateral-monitoring configuration.""" + +from dataclasses import dataclass +from typing import Iterable + +from utils.chains import Chain + +KATANA_USDC = "0x203A662b0BD271A6ed5a60EdFbd04bFce608FD36" +KATANA_USDT = "0x2DCa96907fde857dd3D816880A0df407eeB2D2F2" +KATANA_WETH = "0xEE7D8BCFb72bC1880D0Cf19822eB0A2e6577aB62" +KATANA_WBTC = "0x0913DA6Da4b42f538B445599b46Bb4622342Cf52" + + +@dataclass(frozen=True) +class VaultConfig: + """One vault monitored by the Morpho market and governance jobs.""" + + name: str + address: str + risk_level: int + collateral_asset: str | None = None + + +VAULTS_V1_BY_CHAIN: dict[Chain, tuple[VaultConfig, ...]] = { + Chain.MAINNET: ( + VaultConfig("Yearn USDC", "0x68Aea7b82Df6CcdF76235D46445Ed83f85F845A3", 1), + VaultConfig("Yearn USDT", "0x0963232eB842BAF53E8e517691f81745C1F228a0", 1), + VaultConfig("Yearn WBTC", "0x2bB005127069A0F0325Fb7370967E8A2b64FB77E", 1), + VaultConfig("Yearn OG WETH", "0xE89371eAaAC6D46d4C3ED23453241987916224FC", 2), + VaultConfig("Yearn OG USDC", "0xF9bdDd4A9b3A45f980e11fDDE96e16364dDBEc49", 2), + VaultConfig("OUSD", "0x5B8b9FA8e4145eE06025F642cAdB1B47e5F39F04", 2), + VaultConfig("Vault Bridge USDC", "0xBEefb9f61CC44895d8AEc381373555a64191A9c4", 1), + VaultConfig("Vault Bridge USDT", "0xc54b4E08C1Dcc199fdd35c6b5Ab589ffD3428a8d", 1), + VaultConfig("Vault Bridge WETH", "0x31A5684983EeE865d943A696AAC155363bA024f9", 1), + VaultConfig("Vault Bridge WBTC", "0x812B2C6Ab3f4471c0E43D4BB61098a9211017427", 2), + ), + Chain.BASE: ( + VaultConfig("Moonwell Flagship USDC", "0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca", 1), + VaultConfig("Yearn OG USDC", "0xef417a2512C5a41f69AE4e021648b69a7CdE5D03", 2), + VaultConfig("Yearn OG WETH", "0x1D795E29044A62Da42D927c4b179269139A28A6B", 2), + VaultConfig("OUSD", "0x581Cc9a73Ec7431723A4a80699B8f801205841F1", 2), + ), + Chain.KATANA: ( + VaultConfig("Yearn OG WETH", "0xFaDe0C546f44e33C134c4036207B314AC643dc2E", 1, KATANA_WETH), + VaultConfig("Yearn OG USDC", "0xCE2b8e464Fc7b5E58710C24b7e5EBFB6027f29D7", 1, KATANA_USDC), + VaultConfig("Yearn OG USDT", "0x8ED68f91AfbE5871dCE31ae007a936ebE8511d47", 1, KATANA_USDT), + VaultConfig("Yearn OG WBTC", "0xe107cCdeb8e20E499545C813f98Cc90619b29859", 1, KATANA_WBTC), + VaultConfig("Gauntlet USDC", "0xE4248e2105508FcBad3fe95691551d1AF14015f7", 2, KATANA_USDC), + VaultConfig( + "Steakhouse High Yield USDC", + "0x1445A01a57D7B7663CfD7B4EE0a8Ec03B379aabD", + 3, + KATANA_USDC, + ), + VaultConfig("Gauntlet USDT", "0x1ecDC3F2B5E90bfB55fF45a7476FF98A8957388E", 1, KATANA_USDT), + VaultConfig( + "Steakhouse Prime USDC", + "0x61D4F9D3797BA4dA152238c53a6f93Fb665C3c1d", + 1, + KATANA_USDC, + ), + VaultConfig("Gauntlet WETH", "0xC5e7AB07030305fc925175b25B93b285d40dCdFf", 1, KATANA_WETH), + VaultConfig("Gauntlet WBTC", "0xf243523996ADbb273F0B237B53f30017C4364bBC", 1, KATANA_WBTC), + ), +} + + +VAULTS_V2_BY_CHAIN: dict[Chain, tuple[VaultConfig, ...]] = { + Chain.MAINNET: ( + VaultConfig("Yearn USDC", "0xaA8d9E2aBa210639cE6C7cE21385e7c673ACa6f3", 1), + VaultConfig("Yearn OG WETH V2", "0xbe518068EB6135117207256F8C9aFf81B4382DB1", 1), + VaultConfig("Yearn OG USDC", "0xB885F6d448dA7E2C642Ec31190B629E40E87B069", 2), + VaultConfig("Sentora RLUSD Main", "0x6dC58a0FdfC8D694e571DC59B9A52EEEa780E6bf", 2), + VaultConfig("Sentora PaypalUSD Main", "0xb576765fB15505433aF24FEe2c0325895C559FB2", 2), + ), + 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), + VaultConfig("Yearn OG USDT", "0x4284d4F9f4d61eA57B8F0943547c7C19C5B9B249", 1, KATANA_USDT), + VaultConfig("Yearn OG ETH", "0x5920A6FC553af799542EDA628AdfCc9eA52e141C", 1, KATANA_WETH), + VaultConfig("Yearn KAT", "0x9b1aE9548E4B46cEB6650f6CEc702bAf5CF2b8CC", 1), + VaultConfig("Yearn Degen USDC", "0xA2d38c8A3D810EBcF4C2075821c5eC8F976bb692", 3, KATANA_USDC), + VaultConfig("Gauntlet USDT", "0xaC596AD9771a8d0D4DF108ae0406e6f913aEdceb", 1, KATANA_USDT), + VaultConfig( + "Steakhouse High Yield USDC", + "0xbeeff2d5d126d4809195EeA02b605423917bb6c6", + 2, + KATANA_USDC, + ), + VaultConfig( + "Steakhouse Prime USDC", + "0xbeef042bAD4472c3F7Eb9A73070703788b5362D7", + 1, + KATANA_USDC, + ), + ), +} + + +YV_COLLATERAL_MARKETS_BY_ASSET: dict[Chain, dict[str, tuple[str, ...]]] = { + Chain.KATANA: { + KATANA_USDC: ("0x6691cdcadd5d23ac68d2c1cf54dc97ab8242d2a888230de411094480252c2ed3",), + KATANA_USDT: ("0xcdaf57d98c2f75bffb8f0d3f7aa79bbacda4a479c47e316aab14af1ca6d85ffc",), + KATANA_WETH: ("0x08f67ef41398456dbc5ff72d43c8b6f7917abfd01498a9fc6c89dabe6eb78b8c",), + KATANA_WBTC: ("0x3a22063bd258f3f75e3135cac4ec53435dfa5b47b3d5173bb8fd5278e6c1b305",), + }, +} + + +def iter_vaults(vaults_by_chain: dict[Chain, tuple[VaultConfig, ...]]) -> Iterable[tuple[Chain, VaultConfig]]: + """Yield chain/config pairs from a generation-specific configuration.""" + for chain, vaults in vaults_by_chain.items(): + for vault in vaults: + yield chain, vault + + +def get_vault_query_config( + vaults_by_chain: dict[Chain, tuple[VaultConfig, ...]], +) -> tuple[dict[str, tuple[Chain, VaultConfig]], list[str], list[int]]: + """Flatten vault configuration for GraphQL queries and response joins.""" + entries = list(iter_vaults(vaults_by_chain)) + metadata = {vault.address.lower(): (chain, vault) for chain, vault in entries} + addresses = [vault.address for _, vault in entries] + chain_ids = sorted({chain.chain_id for chain, _ in entries}) + return metadata, addresses, chain_ids + + +def get_vault_config(vault_address: str, chain: Chain, *, version: int) -> VaultConfig: + """Return a configured vault or fail for an unknown address/version.""" + vaults_by_chain = _get_vaults_by_version(version) + for vault in vaults_by_chain.get(chain, ()): + if vault.address.lower() == vault_address.lower(): + return vault + raise ValueError(f"Vault V{version} {vault_address} not found in Morpho configuration") + + +def get_collateral_vaults_by_asset( + chain: Chain, + *, + version: int, +) -> dict[str, list[VaultConfig]]: + """Group collateral-strategy vaults by their underlying asset address.""" + vaults_by_chain = _get_vaults_by_version(version) + grouped: dict[str, list[VaultConfig]] = {} + for vault in vaults_by_chain.get(chain, ()): + if vault.collateral_asset is None: + continue + grouped.setdefault(vault.collateral_asset.lower(), []).append(vault) + return grouped + + +def is_collateral_vault(vault_address: str, chain: Chain, *, version: int) -> bool: + """Return whether a configured vault supplies YV-collateral unwind liquidity.""" + try: + return get_vault_config(vault_address, chain, version=version).collateral_asset is not None + except ValueError: + return False + + +def _get_vaults_by_version(version: int) -> dict[Chain, tuple[VaultConfig, ...]]: + if version == 1: + return VAULTS_V1_BY_CHAIN + if version == 2: + return VAULTS_V2_BY_CHAIN + raise ValueError(f"Unsupported Morpho vault version: {version}") diff --git a/protocols/morpho/governance.py b/protocols/morpho/governance.py index f9b1add..4f554e5 100644 --- a/protocols/morpho/governance.py +++ b/protocols/morpho/governance.py @@ -1,7 +1,18 @@ +from dataclasses import dataclass from datetime import datetime +from typing import Any from web3 import Web3 +from protocols.morpho._shared import ( + PROTOCOL, + MorphoMonitoringError, + execute_graphql, + fetch_market_metadata, + get_market_url, + get_vault_url, +) +from protocols.morpho.config import VAULTS_V1_BY_CHAIN from utils.abi import load_abi from utils.alert import Alert, AlertSeverity, send_alert from utils.cache import ( @@ -10,78 +21,26 @@ ) from utils.chains import Chain from utils.formatting import format_token_amount, format_with_suffix -from utils.http_client import request_with_retry from utils.logger import get_logger from utils.web3_wrapper import ChainManager -PROTOCOL = "morpho" logger = get_logger("morpho.governance") -MORPHO_URL = "https://app.morpho.org" -API_URL = "https://api.morpho.org/graphql" PENDING_CAP_TYPE = "pending_cap" REMOVABLE_AT_TYPE = "removable_at" - -# Map vaults by chain -VAULTS_BY_CHAIN = { - Chain.MAINNET: [ - # ["Steakhouse USDC", "0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB"], - # ["Steakhouse USDT", "0xbEef047a543E45807105E51A8BBEFCc5950fcfBa"], - # ["Gauntlet WETH Prime", "0x2371e134e3455e0593363cBF89d3b6cf53740618"], - # ["Gauntlet USDC Prime", "0xdd0f28e19C1780eb6396170735D45153D261490d"], - # ["Gauntlet USDT Prime", "0x8CB3649114051cA5119141a34C200D65dc0Faa73"], - # ["Gauntlet WETH Core", "0x4881Ef0BF6d2365D3dd6499ccd7532bcdBCE0658"], - # ["Gauntlet USDC Core", "0x8eB67A509616cd6A7c1B3c8C21D48FF57df3d458"], - ["VaultBridge USDC", "0xBEefb9f61CC44895d8AEc381373555a64191A9c4"], - ["VaultBridge USDT", "0xc54b4E08C1Dcc199fdd35c6b5Ab589ffD3428a8d"], - ["VaultBridge WETH", "0x31A5684983EeE865d943A696AAC155363bA024f9"], - ["VaultBridge WBTC", "0x812B2C6Ab3f4471c0E43D4BB61098a9211017427"], - ["Yearn OG WETH", "0xE89371eAaAC6D46d4C3ED23453241987916224FC"], - ["Yearn OG USDC", "0xF9bdDd4A9b3A45f980e11fDDE96e16364dDBEc49"], - ["Yearn USDT", "0x0963232eB842BAF53E8e517691f81745C1F228a0"], - ["Yearn WBTC", "0x2bB005127069A0F0325Fb7370967E8A2b64FB77E"], - ["Yearn USDC", "0x68Aea7b82Df6CcdF76235D46445Ed83f85F845A3"], - ], - Chain.BASE: [ - ["Moonwell Flagship USDC", "0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca"], - ["Yearn OG USDC", "0xef417a2512C5a41f69AE4e021648b69a7CdE5D03"], - ["Yearn OG WETH", "0x1D795E29044A62Da42D927c4b179269139A28A6B"], - ], - Chain.KATANA: [ - ["Gauntlet WBTC", "0xf243523996ADbb273F0B237B53f30017C4364bBC"], - ["Gauntlet USDC", "0xE4248e2105508FcBad3fe95691551d1AF14015f7"], - ["Gauntlet USDT", "0x1ecDC3F2B5E90bfB55fF45a7476FF98A8957388E"], - ["Gauntlet WETH", "0xC5e7AB07030305fc925175b25B93b285d40dCdFf"], - ["Steakhouse Prime USDC", "0x61D4F9D3797BA4dA152238c53a6f93Fb665C3c1d"], - ["Steakhouse High Yield USDC", "0x1445A01a57D7B7663CfD7B4EE0a8Ec03B379aabD"], - ["Yearn OG WETH", "0xFaDe0C546f44e33C134c4036207B314AC643dc2E"], - ["Yearn OG USDC", "0xCE2b8e464Fc7b5E58710C24b7e5EBFB6027f29D7"], - ["Yearn OG USDT", "0x8ED68f91AfbE5871dCE31ae007a936ebE8511d47"], - ["Yearn OG WBTC", "0xe107cCdeb8e20E499545C813f98Cc90619b29859"], - ], -} - - ABI_MORPHO = load_abi("protocols/morpho/abi/morpho.json") -def get_chain_name(chain: Chain): - if chain == Chain.MAINNET: - return "ethereum" - else: - return chain.name.lower() - - -def get_market_url(market, chain: Chain): - return f"{MORPHO_URL}/{get_chain_name(chain)}/market/{market}" +@dataclass(frozen=True) +class MarketGovernanceState: + """Pending governance state for one market in a V1 vault.""" - -def get_vault_url_by_name(vault_name, chain: Chain): - vaults = VAULTS_BY_CHAIN[chain] - for name, address in vaults: - if name == vault_name: - return f"{MORPHO_URL}/{get_chain_name(chain)}/vault/{address}" - return None + vault_address: str + market_id: str + pending_cap: int + pending_cap_timestamp: int + current_cap: int + removable_at: int def fetch_pending_cap_market_ids(vault_address: str, chain: Chain) -> list[str]: @@ -113,15 +72,12 @@ def fetch_pending_cap_market_ids(vault_address: str, chain: Chain) -> list[str]: } """ try: - response = request_with_retry( - "post", - API_URL, - json={"query": query, "variables": {"address": vault_address, "chainId": chain.chain_id}}, + data = execute_graphql( + query, + {"address": vault_address, "chainId": chain.chain_id}, + f"pending caps for {vault_address} on {chain.name}", ) - data = response.json() - items = ( - (((data.get("data") or {}).get("vaultByAddress") or {}).get("state") or {}).get("pendingConfigs") or {} - ).get("items") or [] + items = (((data.get("vaultByAddress") or {}).get("state") or {}).get("pendingConfigs") or {}).get("items") or [] market_ids = [] for item in items: if item.get("functionName") != "SetCap": @@ -132,43 +88,18 @@ def fetch_pending_cap_market_ids(vault_address: str, chain: Chain) -> list[str]: if marketId: market_ids.append(marketId) return market_ids - except Exception as e: + except MorphoMonitoringError as e: logger.warning("Failed to fetch pending caps for vault %s: %s", vault_address, e) return [] def fetch_market_info(market_id: str, chain: Chain) -> tuple[str, int | None]: - """Fetch market name and loan asset decimals from Morpho GraphQL API. - - Returns a tuple of (name, decimals) where name is a human-readable label like - 'WBTC/USDC (86.00%)'. On failure returns (market_id, None). - """ - query = """ - query GetMarket($marketId: String!, $chainId: Int!) { - marketById(marketId: $marketId, chainId: $chainId) { - lltv - loanAsset { symbol, decimals } - collateralAsset { symbol } - } - } - """ - try: - response = request_with_retry( - "post", - API_URL, - json={"query": query, "variables": {"marketId": market_id, "chainId": chain.chain_id}}, - ) - data = response.json() - market = data["data"]["marketById"] - collateral_symbol = market["collateralAsset"]["symbol"] if market.get("collateralAsset") else "idle" - loan_asset = market["loanAsset"] - loan_symbol = loan_asset["symbol"] - decimals = int(loan_asset["decimals"]) - lltv_pct = int(market["lltv"]) / 1e18 * 100 - return f"{collateral_symbol}/{loan_symbol} ({lltv_pct:.2f}%)", decimals - except Exception as e: - logger.warning("Failed to fetch market info for %s: %s", market_id, e) + """Return the shared market label with LLTV and loan-token decimals.""" + metadata = fetch_market_metadata(market_id, chain) + if metadata is None: return market_id, None + lltv_pct = metadata["lltv"] / 1e18 * 100 + return f"{metadata['name']} ({lltv_pct:.2f}%)", int(metadata["loan_decimals"]) def format_cap(cap: int, decimals: int | None) -> str: @@ -178,140 +109,158 @@ def format_cap(cap: int, decimals: int | None) -> str: """ if decimals is None or decimals <= 0: return f"{cap:,}" - return format_with_suffix(format_token_amount(cap, decimals)) + return str(format_with_suffix(format_token_amount(cap, decimals))) -def check_markets_pending_cap(name, morpho_contract, chain, w3): - with w3.batch_requests() as batch: +def _load_vault_market_ids(morpho_contract: Any, chain: Chain, client: Any) -> list[bytes]: + """Load accepted and pending-cap market IDs for one V1 vault.""" + with client.batch_requests() as batch: batch.add(morpho_contract.functions.supplyQueueLength()) batch.add(morpho_contract.functions.withdrawQueueLength()) - - length_responses = w3.execute_batch(batch) - if len(length_responses) != 2: - raise ValueError( - "Expected 2 responses from batch(supplyQueueLength+withdrawQueueLength), got: ", - len(length_responses), - ) - length_of_supply_queue = length_responses[0] - length_of_withdraw_queue = length_responses[1] - - vault_address = morpho_contract.address - with w3.batch_requests() as batch: - for i in range(length_of_supply_queue): - batch.add(morpho_contract.functions.supplyQueue(i)) - for i in range(length_of_withdraw_queue): - batch.add(morpho_contract.functions.withdrawQueue(i)) - market_responses = w3.execute_batch(batch) - if len(market_responses) != length_of_supply_queue + length_of_withdraw_queue: - raise ValueError( - "Expected ", - length_of_supply_queue + length_of_withdraw_queue, - " responses from batch(supplyQueue+withdrawQueue), got: ", - len(market_responses), - ) - - # supplyQueue/withdrawQueue only contain markets that have been accepted at least once. - # Brand-new markets with a pending cap (submitCap called, acceptCap not yet run) are not - # in any queue, so the GraphQL pendingCaps lookup is needed to catch them. - pending_cap_market_ids = { - bytes.fromhex(market_id.removeprefix("0x")) for market_id in fetch_pending_cap_market_ids(vault_address, chain) + lengths = client.execute_batch(batch) + if len(lengths) != 2: + raise ValueError(f"Expected 2 queue length responses, got {len(lengths)}") + + supply_length, withdraw_length = lengths + with client.batch_requests() as batch: + for index in range(supply_length): + batch.add(morpho_contract.functions.supplyQueue(index)) + for index in range(withdraw_length): + batch.add(morpho_contract.functions.withdrawQueue(index)) + queued_markets = client.execute_batch(batch) + expected_count = supply_length + withdraw_length + if len(queued_markets) != expected_count: + raise ValueError(f"Expected {expected_count} queue responses, got {len(queued_markets)}") + + pending_markets = { + bytes.fromhex(market_id.removeprefix("0x")) + for market_id in fetch_pending_cap_market_ids(morpho_contract.address, chain) } - markets = list(set(market_responses) | pending_cap_market_ids) - - with w3.batch_requests() as batch: - for market in markets: - batch.add(morpho_contract.functions.pendingCap(market)) - batch.add(morpho_contract.functions.config(market)) - pending_cap_and_config_responses = w3.execute_batch(batch) - if len(pending_cap_and_config_responses) != len(markets) * 2: - raise ValueError( - "Expected ", - len(markets) * 2, - " responses from batch(pedningCap+config), got: ", - len(pending_cap_and_config_responses), + return list(set(queued_markets) | pending_markets) + + +def _load_market_governance_states( + morpho_contract: Any, + market_ids: list[bytes], + client: Any, +) -> list[MarketGovernanceState]: + """Batch pending-cap and config reads for V1 vault markets.""" + with client.batch_requests() as batch: + for market_id in market_ids: + batch.add(morpho_contract.functions.pendingCap(market_id)) + batch.add(morpho_contract.functions.config(market_id)) + responses = client.execute_batch(batch) + expected_count = len(market_ids) * 2 + if len(responses) != expected_count: + raise ValueError(f"Expected {expected_count} pendingCap/config responses, got {len(responses)}") + + states = [] + for index, market_id in enumerate(market_ids): + pending_cap, pending_timestamp = responses[index * 2] + config = responses[index * 2 + 1] + states.append( + MarketGovernanceState( + vault_address=morpho_contract.address, + market_id=Web3.to_hex(market_id), + pending_cap=pending_cap, + pending_cap_timestamp=pending_timestamp, + current_cap=config[0], + removable_at=config[2], ) - - for i in range(0, len(markets)): - market_id = markets[i] - market = Web3.to_hex(market_id) - - # Multiply by 2 because there were 2 responses per market and get - pending_value = pending_cap_and_config_responses[i * 2] - pending_cap_value = pending_value[0] - pending_cap_timestamp = pending_value[1] - - # get the current config of the market - config = pending_cap_and_config_responses[i * 2 + 1] # Use i * 2 + 1 for config - current_cap = config[0] # current cap value is at index 0 in config struct - - # generat urls - market_url = get_market_url(market, chain) - vault_url = get_vault_url_by_name(name, chain) - - # pending_cap check - # Don't skip past timestamps: a pending cap whose timelock has expired but hasn't been - # accepted yet is still pending action, and may have been missed by earlier runs (e.g., - # if the market was brand-new and not yet visible to the on-chain queue iteration). - # The cache check below dedupes so we only alert once per unique timestamp. - if pending_cap_timestamp > 0: - last_executed_morpho = get_last_executed_morpho_from_file(vault_address, market, PENDING_CAP_TYPE) - - if pending_cap_timestamp > last_executed_morpho: - time = datetime.fromtimestamp(pending_cap_timestamp).strftime("%Y-%m-%d %H:%M:%S") - market_name, decimals = fetch_market_info(market, chain) - pending_cap_str = format_cap(pending_cap_value, decimals) - if current_cap == 0: - message = ( - f"Adding new market [{market_name}]({market_url}) with cap {pending_cap_str} " - f"to vault [{name}]({vault_url}) on {chain.name}. " - f"Queued for {time}" - ) - else: - difference_in_percentage = ((pending_cap_value - current_cap) / current_cap) * 100 - current_cap_str = format_cap(current_cap, decimals) - message = ( - f"Updating cap to new cap {pending_cap_str}, current cap {current_cap_str}, " - f"difference: {difference_in_percentage:.2f}%. \n" - f"For vault [{name}]({vault_url}) for market: [{market_name}]({market_url}) on {chain.name}. " - f"Queued for {time}" - ) - send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) - write_last_executed_morpho_to_file(vault_address, market, PENDING_CAP_TYPE, pending_cap_timestamp) - else: - logger.info( - "Skipping pending cap update for vault %s(%s) for market: %s because it was already executed", - name, - vault_url, - market_url, - ) - - # removable_at check - removable_at = config[2] # removable_at value is at index 2 in config struct - if removable_at > 0: - if removable_at > get_last_executed_morpho_from_file(vault_address, market, REMOVABLE_AT_TYPE): - time = datetime.fromtimestamp(removable_at).strftime("%Y-%m-%d %H:%M:%S") - market_name, _ = fetch_market_info(market, chain) - send_alert( - Alert( - AlertSeverity.MEDIUM, - f"Vault [{name}]({vault_url}) queued to remove market: [{market_name}]({market_url}) at {time}", - PROTOCOL, - ) - ) - write_last_executed_morpho_to_file(vault_address, market, REMOVABLE_AT_TYPE, removable_at) - else: - logger.info( - "Skipping removable_at update for vault %s(%s) for market: %s because it was already executed", - name, - vault_url, - market_url, - ) - - -def check_pending_role_change(name, morpho_contract, role_type, timestamp, chain): + ) + return states + + +def _check_pending_cap(name: str, state: MarketGovernanceState, chain: Chain) -> None: + """Alert once for a new pending V1 market cap.""" + if state.pending_cap_timestamp <= 0: + return + last_timestamp = get_last_executed_morpho_from_file( + state.vault_address, + state.market_id, + PENDING_CAP_TYPE, + ) + if state.pending_cap_timestamp <= last_timestamp: + logger.info("Skipping previously alerted cap update for %s market %s", name, state.market_id) + return + + market_url = get_market_url(state.market_id, chain) + vault_url = get_vault_url(state.vault_address, chain) + market_name, decimals = fetch_market_info(state.market_id, chain) + pending_cap = format_cap(state.pending_cap, decimals) + queued_for = datetime.fromtimestamp(state.pending_cap_timestamp).strftime("%Y-%m-%d %H:%M:%S") + if state.current_cap == 0: + message = ( + f"Adding new market [{market_name}]({market_url}) with cap {pending_cap} " + f"to vault [{name}]({vault_url}) on {chain.name}. Queued for {queued_for}" + ) + else: + difference = ((state.pending_cap - state.current_cap) / state.current_cap) * 100 + current_cap = format_cap(state.current_cap, decimals) + message = ( + f"Updating cap to new cap {pending_cap}, current cap {current_cap}, difference: {difference:.2f}%. \n" + f"For vault [{name}]({vault_url}) for market: [{market_name}]({market_url}) on {chain.name}. " + f"Queued for {queued_for}" + ) + send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) + write_last_executed_morpho_to_file( + state.vault_address, + state.market_id, + PENDING_CAP_TYPE, + state.pending_cap_timestamp, + ) + + +def _check_market_removal(name: str, state: MarketGovernanceState, chain: Chain) -> None: + """Alert once for a newly queued V1 market removal.""" + if state.removable_at <= 0: + return + last_timestamp = get_last_executed_morpho_from_file( + state.vault_address, + state.market_id, + REMOVABLE_AT_TYPE, + ) + if state.removable_at <= last_timestamp: + logger.info("Skipping previously alerted market removal for %s market %s", name, state.market_id) + return + + market_url = get_market_url(state.market_id, chain) + vault_url = get_vault_url(state.vault_address, chain) + market_name, _ = fetch_market_info(state.market_id, chain) + removable_at = datetime.fromtimestamp(state.removable_at).strftime("%Y-%m-%d %H:%M:%S") + message = f"Vault [{name}]({vault_url}) queued to remove market: [{market_name}]({market_url}) at {removable_at}" + send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) + write_last_executed_morpho_to_file( + state.vault_address, + state.market_id, + REMOVABLE_AT_TYPE, + state.removable_at, + ) + + +def check_market_governance_state(name: str, state: MarketGovernanceState, chain: Chain) -> None: + """Check pending cap and removal changes for one V1 market.""" + _check_pending_cap(name, state, chain) + _check_market_removal(name, state, chain) + + +def check_markets_pending_cap(name: str, morpho_contract: Any, chain: Chain, client: Any) -> None: + """Check V1 market cap and removal governance for one vault.""" + market_ids = _load_vault_market_ids(morpho_contract, chain, client) + for state in _load_market_governance_states(morpho_contract, market_ids, client): + check_market_governance_state(name, state, chain) + + +def check_pending_role_change( + name: str, + morpho_contract: Any, + role_type: str, + timestamp: int, + chain: Chain, +) -> None: market_id = "" # use empty string for all markets because the value is used per vault if timestamp > get_last_executed_morpho_from_file(morpho_contract.address, market_id, role_type): - vault_url = get_vault_url_by_name(name, chain) + vault_url = get_vault_url(morpho_contract.address, chain) send_alert( Alert( AlertSeverity.HIGH, @@ -322,7 +271,7 @@ def check_pending_role_change(name, morpho_contract, role_type, timestamp, chain write_last_executed_morpho_to_file(morpho_contract.address, market_id, role_type, timestamp) -def check_timelock_and_guardian(name, morpho_contract, chain, client): +def check_timelock_and_guardian(name: str, morpho_contract: Any, chain: Chain, client: Any) -> None: with morpho_contract.w3.batch_requests() as batch: batch.add(morpho_contract.functions.pendingTimelock()) batch.add(morpho_contract.functions.pendingGuardian()) @@ -337,20 +286,20 @@ def check_timelock_and_guardian(name, morpho_contract, chain, client): check_pending_role_change(name, morpho_contract, "guardian", guardian, chain) -def get_data_for_chain(chain: Chain): +def get_data_for_chain(chain: Chain) -> None: client = ChainManager.get_client(chain) - vaults = VAULTS_BY_CHAIN[chain] + vaults = VAULTS_V1_BY_CHAIN[chain] logger.info("Processing Morpho Vaults on %s ...", chain.name) logger.debug("Vaults: %s", vaults) for vault in vaults: - morpho_contract = client.eth.contract(address=vault[1], abi=ABI_MORPHO) - check_markets_pending_cap(vault[0], morpho_contract, chain, client) - check_timelock_and_guardian(vault[0], morpho_contract, chain, client) + morpho_contract = client.eth.contract(address=vault.address, abi=ABI_MORPHO) + check_markets_pending_cap(vault.name, morpho_contract, chain, client) + check_timelock_and_guardian(vault.name, morpho_contract, chain, client) -def main(): +def main() -> None: get_data_for_chain(Chain.MAINNET) get_data_for_chain(Chain.KATANA) get_data_for_chain(Chain.BASE) diff --git a/protocols/morpho/governance_v2.py b/protocols/morpho/governance_v2.py index 16934bb..7828119 100644 --- a/protocols/morpho/governance_v2.py +++ b/protocols/morpho/governance_v2.py @@ -25,10 +25,16 @@ from datetime import datetime from typing import Any, Dict, List -import requests from web3 import Web3 -from protocols.morpho._shared import API_URL, SUPPORTED_CHAINS, VAULTS_V2_BY_CHAIN, get_vault_url +from protocols.morpho._shared import ( + PROTOCOL, + MorphoV2MonitoringError, + execute_graphql, + get_vault_url, + require_configured_keys, +) +from protocols.morpho.config import VAULTS_V2_BY_CHAIN, get_vault_query_config from protocols.morpho.v2_decoders import decode_submit, submit_data_key from utils.alert import Alert, AlertSeverity, send_alert from utils.cache import ( @@ -38,10 +44,8 @@ write_last_value_to_file, ) from utils.chains import Chain -from utils.http_client import request_with_retry from utils.logger import get_logger -PROTOCOL = "morpho" logger = get_logger("morpho.governance_v2") # Cache value-type tags used with utils.cache.morpho_key. @@ -96,7 +100,7 @@ class PendingConfig: @property def data_hash(self) -> str: """Stable cache-key hash for this pending operation.""" - return submit_data_key(self.data) + return str(submit_data_key(self.data)) @dataclass @@ -106,7 +110,6 @@ class V2GovernanceSnapshot: name: str address: str # checksummed chain: Chain - risk_level: int owner: str # checksummed curator: str sentinels: List[str] # checksummed, sorted @@ -129,7 +132,7 @@ def _hex_to_bytes(value: str) -> bytes: def _checksum_or_empty(value: str) -> str: if not value: return "" - return Web3.to_checksum_address(value) + return str(Web3.to_checksum_address(value)) def fetch_governance_snapshots() -> Dict[Chain, List[V2GovernanceSnapshot]]: @@ -138,47 +141,30 @@ def fetch_governance_snapshots() -> Dict[Chain, List[V2GovernanceSnapshot]]: Issues a single ``vaultV2s(where: { address_in })`` query and joins the result back to the static list to inherit the configured risk level. """ - addr_to_meta: dict[str, tuple[Chain, str, int]] = {} - addresses: list[str] = [] - chain_ids: list[int] = [] - for chain, vaults in VAULTS_V2_BY_CHAIN.items(): - chain_ids.append(chain.chain_id) - for entry in vaults: - name, address, risk = str(entry[0]), _checksum_or_empty(str(entry[1])), int(str(entry[2])) - addr_to_meta[address.lower()] = (chain, name, risk) - addresses.append(address) + addr_to_meta, addresses, chain_ids = get_vault_query_config(VAULTS_V2_BY_CHAIN) if not addresses: return {} - try: - response = request_with_retry( - "post", - API_URL, - json={ - "query": _GOVERNANCE_QUERY, - "variables": {"addresses": addresses, "chainIds": sorted(set(chain_ids))}, - }, - ) - except requests.RequestException as e: - logger.warning("Failed to fetch v2 governance snapshot: %s", e) - return {chain: [] for chain in SUPPORTED_CHAINS} - - payload = response.json() - if "errors" in payload: - logger.warning("GraphQL errors fetching v2 governance: %s", payload["errors"]) - return {chain: [] for chain in SUPPORTED_CHAINS} - - items = payload.get("data", {}).get("vaultV2s", {}).get("items") or [] + data = execute_graphql( + _GOVERNANCE_QUERY, + {"addresses": addresses, "chainIds": chain_ids}, + "Vault V2 governance", + error_type=MorphoV2MonitoringError, + ) + items = data.get("vaultV2s", {}).get("items") or [] by_addr: dict[str, dict[str, Any]] = {item["address"].lower(): item for item in items} + require_configured_keys( + addr_to_meta, + by_addr, + "Vault V2 governance addresses", + error_type=MorphoV2MonitoringError, + ) - result: Dict[Chain, List[V2GovernanceSnapshot]] = {chain: [] for chain in SUPPORTED_CHAINS} + result: Dict[Chain, List[V2GovernanceSnapshot]] = {chain: [] for chain in VAULTS_V2_BY_CHAIN} - for addr_lc, (chain, name, risk_level) in addr_to_meta.items(): - item = by_addr.get(addr_lc) - if item is None: - logger.warning("V2 vault %s on %s missing from GraphQL response", addr_lc, chain.name) - continue + for addr_lc, (chain, config) in addr_to_meta.items(): + item = by_addr[addr_lc] sentinels = [_checksum_or_empty(s["sentinel"]["address"]) for s in (item.get("sentinels") or [])] allocators = [_checksum_or_empty(a["allocator"]["address"]) for a in (item.get("allocators") or [])] @@ -195,10 +181,9 @@ def fetch_governance_snapshots() -> Dict[Chain, List[V2GovernanceSnapshot]]: result.setdefault(chain, []).append( V2GovernanceSnapshot( - name=name, + name=config.name, address=_checksum_or_empty(item["address"]), chain=chain, - risk_level=risk_level, owner=_checksum_or_empty((item.get("owner") or {}).get("address") or ""), curator=_checksum_or_empty((item.get("curator") or {}).get("address") or ""), sentinels=sorted(sentinels), @@ -271,7 +256,7 @@ def _explorer_link(chain: Chain, tx_hash: str) -> str: def _operation_label(snapshot: V2GovernanceSnapshot, pc: PendingConfig) -> str: decoded = decode_submit(pc.data, snapshot.chain) if decoded: - return decoded + return str(decoded) return pc.function_name or f"`{pc.data_hash[:10]}…`" @@ -284,7 +269,7 @@ def _operation_function_name(pc: PendingConfig, operation_label: str) -> str: def _pending_function_key(snapshot: V2GovernanceSnapshot, data_hash: str) -> str: - return morpho_key(snapshot.address.lower(), data_hash, PENDING_FUNCTION_TYPE) + return str(morpho_key(snapshot.address.lower(), data_hash, PENDING_FUNCTION_TYPE)) def _alert_pending_new(snapshot: V2GovernanceSnapshot, pc: PendingConfig, operation_label: str) -> None: @@ -450,14 +435,19 @@ def main() -> None: logger.info("No matching V2 vaults found; nothing to monitor yet.") return + failures: List[str] = [] for chain, vaults in snapshots_by_chain.items(): if not vaults: continue for vault in vaults: try: diff_and_alert(vault) - except Exception: + except Exception as e: logger.exception("Failed to process governance for %s on %s", vault.address, chain.name) + failures.append(f"{vault.name} on {chain.name}: {type(e).__name__}: {e}") + + if failures: + raise MorphoV2MonitoringError("Failed Morpho Vault V2 governance checks: " + "; ".join(failures)) if __name__ == "__main__": diff --git a/protocols/morpho/markets.py b/protocols/morpho/markets.py index 2ceb07e..41b6f29 100644 --- a/protocols/morpho/markets.py +++ b/protocols/morpho/markets.py @@ -9,435 +9,46 @@ from typing import Any, Dict, List -import requests - +from protocols.morpho._shared import ( + PROTOCOL, + MorphoMonitoringError, + execute_graphql, + format_low_liquidity_message, + get_market_url, + get_vault_url, + require_configured_keys, +) +from protocols.morpho.config import ( + VAULTS_V1_BY_CHAIN, + VAULTS_V2_BY_CHAIN, + YV_COLLATERAL_MARKETS_BY_ASSET, + get_collateral_vaults_by_asset, + get_vault_config, + is_collateral_vault, + iter_vaults, +) +from protocols.morpho.risk import ( + LIQUIDITY_THRESHOLD, + MAX_RISK_THRESHOLDS, + assess_exposure, + get_market_risk_level, + is_bad_debt_excessive, + is_low_liquidity, +) from utils.alert import Alert, AlertSeverity, send_alert from utils.chains import Chain -from utils.http_client import request_with_retry from utils.logger import get_logger -from utils.telegram import send_error_message # Configuration constants -API_URL = "https://api.morpho.org/graphql" -MORPHO_URL = "https://app.morpho.org" -PROTOCOL = "morpho" logger = get_logger(PROTOCOL) -BAD_DEBT_RATIO = 0.005 # 0.5% of total borrowed tvl -LIQUIDITY_THRESHOLD = 0.01 # 1% of total assets YV_COLLATERAL_LIQUIDATION_BUFFER = 1.25 # require 25% more withdrawable liquidity than collateral at risk YV_COLLATERAL_MIN_BORROW_USD = 10_000 # skip dust markets -YV_COLLATERAL_MIN_GROUP_ASSETS_USD = 10_000 # skip liquidity groups with negligible assets YV_COLLATERAL_MIN_AT_RISK_USD = 10_000 # skip markets with negligible collateral at risk -YV_COLLATERAL_AT_RISK_POINTS = 20 # requested granularity for Morpho's collateral-at-risk curve -YV_COLLATERAL_STABLE_PRICE_SHOCK = 0.05 +YV_COLLATERAL_AT_RISK_POINTS = 50 # 2% increments for the stable-market shock +YV_COLLATERAL_STABLE_PRICE_SHOCK = 0.02 YV_COLLATERAL_VOLATILE_PRICE_SHOCK = 0.15 YV_COLLATERAL_FALLBACK_PRICE_SHOCK = 0.10 -# Map vaults by chain -VAULTS_BY_CHAIN = { - Chain.MAINNET: [ - # name, address, risk level - # ["Steakhouse USDC", "0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB", 1], - # ["Steakhouse USDT", "0xbEef047a543E45807105E51A8BBEFCc5950fcfBa", 1], - # ["Gauntlet WETH Prime", "0x2371e134e3455e0593363cBF89d3b6cf53740618", 1], - # ["Gauntlet USDC Prime", "0xdd0f28e19C1780eb6396170735D45153D261490d", 1], - # ["Gauntlet USDT Prime", "0x8CB3649114051cA5119141a34C200D65dc0Faa73", 1], - ["Yearn USDC", "0x68Aea7b82Df6CcdF76235D46445Ed83f85F845A3", 1], - ["Yearn USDT", "0x0963232eB842BAF53E8e517691f81745C1F228a0", 1], - ["Yearn WBTC", "0x2bB005127069A0F0325Fb7370967E8A2b64FB77E", 1], - ["Yearn OG WETH", "0xE89371eAaAC6D46d4C3ED23453241987916224FC", 2], - [ - "Yearn OG USDC", - "0xF9bdDd4A9b3A45f980e11fDDE96e16364dDBEc49", - 2, - ], - ["OUSD", "0x5B8b9FA8e4145eE06025F642cAdB1B47e5F39F04", 2], - # Vault Bridge for Katana Chain - ["Vault Bridge USDC", "0xBEefb9f61CC44895d8AEc381373555a64191A9c4", 1], - ["Vault Bridge USDT", "0xc54b4E08C1Dcc199fdd35c6b5Ab589ffD3428a8d", 1], - ["Vault Bridge WETH", "0x31A5684983EeE865d943A696AAC155363bA024f9", 1], - ["Vault Bridge WBTC", "0x812B2C6Ab3f4471c0E43D4BB61098a9211017427", 2], - ], - Chain.BASE: [ - ["Moonwell Flagship USDC", "0xc1256Ae5FF1cf2719D4937adb3bbCCab2E00A2Ca", 1], - ["Yearn OG USDC", "0xef417a2512C5a41f69AE4e021648b69a7CdE5D03", 2], - ["Yearn OG WETH", "0x1D795E29044A62Da42D927c4b179269139A28A6B", 2], - ["OUSD", "0x581Cc9a73Ec7431723A4a80699B8f801205841F1", 2], - ], - Chain.KATANA: [ - ["Yearn OG WETH", "0xFaDe0C546f44e33C134c4036207B314AC643dc2E", 1], - ["Yearn OG USDC", "0xCE2b8e464Fc7b5E58710C24b7e5EBFB6027f29D7", 1], - ["Yearn OG USDT", "0x8ED68f91AfbE5871dCE31ae007a936ebE8511d47", 1], - ["Yearn OG WBTC", "0xe107cCdeb8e20E499545C813f98Cc90619b29859", 1], - ["Gauntlet USDC", "0xE4248e2105508FcBad3fe95691551d1AF14015f7", 2], - ["SteakhouseHigh Yield USDC", "0x1445A01a57D7B7663CfD7B4EE0a8Ec03B379aabD", 3], - ["Gauntlet USDT", "0x1ecDC3F2B5E90bfB55fF45a7476FF98A8957388E", 1], - ["SteakhousePrime USDC", "0x61D4F9D3797BA4dA152238c53a6f93Fb665C3c1d", 1], - ["Gauntlet WETH", "0xC5e7AB07030305fc925175b25B93b285d40dCdFf", 1], - ["Gauntlet WBTC", "0xf243523996ADbb273F0B237B53f30017C4364bBC", 1], - # ["SteakhousePrime AUSD", "0x82c4C641CCc38719ae1f0FBd16A64808d838fDfD", 1], - # ["Gauntlet AUSD", "0x9540441C503D763094921dbE4f13268E6d1d3B56", 1], - ], -} - -# Morpho Vaults that are used by Yearn Strategies which are used as YV collateral in Morpho Markets -# Organized by asset address for easier grouping and management -VAULTS_WITH_YV_COLLATERAL_BY_ASSET = { - # NOTE: Mainnet is disabled because there is no borrowing demand for yvUSDC as collateral - # Chain.MAINNET: { - # # USDC vaults - # "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": [ - # ["OEV USDC", "0x68Aea7b82Df6CcdF76235D46445Ed83f85F845A3"], - # ["SteakhousePrime USDC", "0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB"], - # ["Gauntlet USDC Prime", "0xdd0f28e19C1780eb6396170735D45153D261490d"], - # ], - # # WETH vaults - # "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": [ - # ["Gauntlet WETH Prime", "0x2371e134e3455e0593363cBF89d3b6cf53740618"], - # ], - # }, - Chain.KATANA: { - # USDC vaults - using addresses from VAULTS_BY_CHAIN as source of truth - "0x203A662b0BD271A6ed5a60EdFbd04bFce608FD36": [ # USDC asset address on Katana - ["Yearn OG USDC", "0xCE2b8e464Fc7b5E58710C24b7e5EBFB6027f29D7"], - ["SteakhousePrime USDC", "0x61D4F9D3797BA4dA152238c53a6f93Fb665C3c1d"], - ["SteakhouseHigh Yield USDC", "0x1445A01a57D7B7663CfD7B4EE0a8Ec03B379aabD"], - ["Gauntlet USDC", "0xE4248e2105508FcBad3fe95691551d1AF14015f7"], - ], - # USDT vaults - using addresses from VAULTS_BY_CHAIN as source of truth - "0x2DCa96907fde857dd3D816880A0df407eeB2D2F2": [ # USDT asset address on Katana - [ - "Yearn OG USDT", - "0x8ED68f91AfbE5871dCE31ae007a936ebE8511d47", - ], # Corrected from VAULTS_BY_CHAIN - ["Gauntlet USDT", "0x1ecDC3F2B5E90bfB55fF45a7476FF98A8957388E"], - ], - # WETH vaults - using addresses from VAULTS_BY_CHAIN as source of truth - "0xEE7D8BCFb72bC1880D0Cf19822eB0A2e6577aB62": [ # WETH asset address on Katana - [ - "Gauntlet WETH", - "0xC5e7AB07030305fc925175b25B93b285d40dCdFf", - ], # Corrected from VAULTS_BY_CHAIN - ["Yearn OG WETH", "0xFaDe0C546f44e33C134c4036207B314AC643dc2E"], - ], - # WBTC vaults - using addresses from VAULTS_BY_CHAIN as source of truth - "0x0913DA6Da4b42f538B445599b46Bb4622342Cf52": [ # WBTC asset address on Katana - ["Yearn OG WBTC", "0xe107cCdeb8e20E499545C813f98Cc90619b29859"], - ["Gauntlet WBTC", "0xf243523996ADbb273F0B237B53f30017C4364bBC"], - ], - # NOTE: skip AUSD vaults because it is used in low amounts as collateral - # AUSD vaults - using addresses from VAULTS_BY_CHAIN as source of truth - # "0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a": [ # AUSD asset address on Katana - # ["SteakhousePrime AUSD", "0x82c4C641CCc38719ae1f0FBd16A64808d838fDfD"], - # ["Gauntlet AUSD", "0x9540441C503D763094921dbE4f13268E6d1d3B56"], - # ], - }, -} - - -# Direct Yearn vault collateral markets to check for unwind liquidity. -# Keys are underlying asset addresses used in VAULTS_WITH_YV_COLLATERAL_BY_ASSET. -YV_COLLATERAL_MARKETS_BY_ASSET = { - Chain.KATANA: { - "0x203A662b0BD271A6ed5a60EdFbd04bFce608FD36": [ - "0x6691cdcadd5d23ac68d2c1cf54dc97ab8242d2a888230de411094480252c2ed3", # yvvbUSDC/vbUSDT - ], - "0x2DCa96907fde857dd3D816880A0df407eeB2D2F2": [ - "0xcdaf57d98c2f75bffb8f0d3f7aa79bbacda4a479c47e316aab14af1ca6d85ffc", # yvvbUSDT/vbUSDC - ], - "0xEE7D8BCFb72bC1880D0Cf19822eB0A2e6577aB62": [ - "0x08f67ef41398456dbc5ff72d43c8b6f7917abfd01498a9fc6c89dabe6eb78b8c", # yvvbETH/vbUSDC - ], - "0x0913DA6Da4b42f538B445599b46Bb4622342Cf52": [ - "0x3a22063bd258f3f75e3135cac4ec53435dfa5b47b3d5173bb8fd5278e6c1b305", # yvvbWBTC/vbUSDC - ], - }, -} - - -MARKETS_RISK_1 = { - Chain.MAINNET: [ - "0x3a85e619751152991742810df6ec69ce473daef99e28a64ab2340d7b7ccfee49", # WBTC/USDC -> lltv 86%, oracle: chainlink WBTC/BTC, chainlink BTC/USD and chainlink USDC/USD - "0xb323495f7e4148be5643a4ea4a8221eef163e4bccfdedc2a6f4696baacbc86cc", # wstETH/USDC -> lltv 86%, oracle: compound oracle wstETH/ETH, chainlink ETH/USD and chainlink USDC/USD - "0x7e585a933ffe8443c371b4f8cfeb4430f5f6a14c2f32a898c26662c67a1cb8b8", # wstETH/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2 — Compound WstETHPriceFeed (Chainlink STETH/ETH feed + Lido wstETH wrapper) + Chainlink ETH/USD; no quote feed (USDC = $1). - "0x94b823e6bd8ea533b4e33fbc307faea0b307301bc48763acc4d4aa4def7636cd", # WETH/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, Chainlink ETH/USD; no quote feed (USDC = $1). - "0x64d65c9a2d91c36d56fbc42d69e979335320169b3df63bf92789e2c8883fcc64", # cbBTC/USDC -> lltv 86%, oracle: chainlink BTC/USD and chainlink USDC/USD - "0xb8fc70e82bc5bb53e773626fcc6a23f7eefa036918d7ef216ecfb1950a94a85e", # wstETH/WETH -> lltv 96.5%, oracle: lido exchange rate - "0xc54d7acf14de29e0e5527cabd7a576506870346a78a11a6762e2cca66322ec41", # wstETH/WETH -> lltv 94.5%, oracle: compound oracle wstETH/ETH - "0xd0e50cdac92fe2172043f5e0c36532c6369d24947e40968f34a5e8819ca9ec5d", # wstETH/WETH -> lltv 94.5%, oracle: lido exchange rate - "0x138eec0e4a1937eb92ebc70043ed539661dd7ed5a89fb92a720b341650288a40", # WBTC/WETH -> lltv 91.5%, oracle: chainlink BTC/ETH - "0x2cbfb38723a8d9a2ad1607015591a78cfe3a5949561b39bde42c242b22874ec0", # cbBTC/WETH -> lltv 91.5%, oracle: chainlink BTC/USD and chainlink ETH/USD - "0xa921ef34e2fc7a27ccc50ae7e4b154e16c9799d3387076c421423ef52ac4df99", # WBTC/USDT -> lltv 86%, oracle: chainlink WBTC/BTC, chainlink BTC/USD and chainlink USDT/USD - "0x3274643db77a064abd3bc851de77556a4ad2e2f502f4f0c80845fa8f909ecf0b", # sUSDS/USDT -> lltv 96.5%, oracle: chainlink USDT/USD, chainlink DAI/USD and sUSDS vault - "0xe7e9694b754c4d4f7e21faf7223f6fa71abaeb10296a4c43a54a7977149687d2", # wstETH/USDT -> lltv 86%, oracle: compound oracle wstETH/ETH, chainlink ETH/USD and chainlink USDT/USD - "0x37e7484d642d90f14451f1910ba4b7b8e4c3ccdd0ec28f8b2bdb35479e472ba7", # weETH/WETH -> lltv 94.5%, oracle: origami weETH/ETH which calls WEETH.getRate(). Alike assets. - "0x45671fb8d5dea1c4fbca0b8548ad742f6643300eeb8dbd34ad64a658b2b05bca", # cbBTC/USDT -> lltv 86%, oracle: chainlink BTC/USD, hardcoded USDT=USD. - "0x4fe72543c5c95cd6b5f3cb516cd235ba882e2e705fe3424db6f99dfe5811d0d3", # cbBTC/USDT -> lltv 86%, oracle: MetaOracleDeviationTimelock — primary MorphoChainlinkOracleV2 Chainlink BTC/USD, backup MorphoChainlinkOracleV2 Chainlink cbBTC/USD; quote feeds unset (USDT=$1). - "0x39d6cc9211d023cc16708a2378d821d394d8cfaa3640e3a4d4638d292e10035d", # cbBTC/WBTC -> lltv 94.5%, oracle: chainlink cbBTC/USD and chainlink WBTC/USD. - "0xab04bdfbeef6de62e3020d44710d6461eccfb901b9659f866844805fca115f2f", # cbBTC/WBTC -> lltv 94.5%, oracle: MetaOracleDeviationTimelock — primary fixed 1:1 cbBTC/WBTC (price 1e36); backup MorphoChainlinkOracleV2 Chainlink cbBTC/USD and Chainlink BTC/USD. - "0x34377fc4f617c51818e92c79df31ff270c6a91bc94ad32e367fdf59b9f4ac5dd", # weETH/USDC -> lltv 77%, oracle: Chainlink weETH/ETH exchange rate and Chainlink ETH/USD; Morpho dummy quote feed (USDC = $1). - "0xf6a056627a51e511ec7f48332421432ea6971fc148d8f3c451e14ea108026549", # LBTC/WBTC -> lltv 94.5%, oracle: readstone exchange rate LBTC/BTC and chainlink WBTC/BTC - ], - Chain.BASE: [ - "0x7fc498ddcb7707d6f85f6dc81f61edb6dc8d7f1b47a83b55808904790564929a", # cbETH/EURC -> lltv 86%, oracle: Chainlink cbETH/ETH and Chainlink ETH/USD and Chainlink EURC/USD. - "0xa9b5142fa687a24c275faf731f13b52faa9873252bb4e1cb6077aa1f412edb0b", # WETH/EURC -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink EURC/USD. - "0x67ebd84b2fb39e3bc5a13d97e4c07abe1ea617e40654826e9abce252e95f049e", # cbBTC/EURC -> lltv 86%, oracle: Chainlink BTC/USD and Chainlink EURC/USD. - "0xf7e40290f8ca1d5848b3c129502599aa0f0602eb5f5235218797a34242719561", # wstETH/EURC -> lltv 86%, oracle: Chainlink wstETH-stETH Exchange Rate and Chainlink ETH/USD and Chainlink EURC/USD. - "0x8793cf302b8ffd655ab97bd1c695dbd967807e8367a65cb2f4edaf1380ba1bda", # WETH/USDC -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink USDC/USD. - "0x9103c3b4e834476c9a62ea009ba2c884ee42e94e6e314a26f04d312434191836", # cbBTC/USDC -> lltv 86%, oracle: Chainlink BTC/USD and Chainlink USDC/USD. - "0x13c42741a359ac4a8aa8287d2be109dcf28344484f91185f9a79bd5a805a55ae", # wstETH/USDC -> lltv 86%, oracle: Chainlink wstETH-stETH Exchange Rate and Chainlink ETH/USD and Chainlink USDC/USD. - "0x1c21c59df9db44bf6f645d854ee710a8ca17b479451447e9f56758aee10a2fad", # cbETH/USDC -> lltv 86%, oracle: Chainlink cbETH/ETH and Chainlink ETH/USD and Chainlink USDC/USD. - "0xdb0bc9f10a174f29a345c5f30a719933f71ccea7a2a75a632a281929bba1b535", # rETH/USDC -> lltv 86%, oracle: Chainlink rETH/ETH and Chainlink ETH/USD and Chainlink USDC/USD. - "0x3a4048c64ba1b375330d376b1ce40e4047d03b47ab4d48af484edec9fec801ba", # wstETH/WETH -> lltv 94.5%, oracle: Chainlink wstETH-stETH Exchange Rate - "0x84662b4f95b85d6b082b68d32cf71bb565b3f22f216a65509cc2ede7dccdfe8c", # cbETH/WETH -> lltv 94.5%, oracle: Chainlink cbETH-ETH Exchange Rate - "0x5dffffc7d75dc5abfa8dbe6fad9cbdadf6680cbe1428bafe661497520c84a94c", # cbBTC/WETH -> lltv 91.5%, oracle: Chainlink BTC/USD and Chainlink ETH/USD - "0xa7813c754ddd6a24e1a1a29ff3ea877803ac63d09efc2f121b1cf3f0bf3af2f6", # WETH/cbBTC -> lltv 91.5%, oracle: Chainlink ETH/USD and Chainlink BTC/USD - "0x3b3769cfca57be2eaed03fcc5299c25691b77781a1e124e7a8d520eb9a7eabb5", # USDC/WETH -> lltv 86.5%, oracle: Chainlink USDC/USD and Chainlink ETH/USD - ], - Chain.KATANA: [ - "0xcd2dc555dced7422a3144a4126286675449019366f83e9717be7c2deb3daae3e", # vbWBTC/vbUSDC -> lltv 86%, oracle: Chainlink WBTC/BTC, Chainlink BTC/USD and Chainlink USDC/USD - "0x2fb14719030835b8e0a39a1461b384ad6a9c8392550197a7c857cf9fcbd6c534", # vbETH/vbUSDC -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink USDC/USD - "0x60b54e17d55b765955a20908ed5143192a48df7fd3833f7f7fe86504bf6c4c1a", # LBTC/vbBTC -> lltv 91.5%, oracle: RedStone Price Feed for LBTC_FUNDAMENTAL -> Katana WBTC vault bridge accepts LBTC markets as collateral, no additional risk when using LBTC on Katana chain - "0xd3b3c992070b5a6271b11acde46cdad575e4187e499782e084d73e523153f1ed", # wstETH/vbUSDC -> lltv 86%, oracle: Chainlink wsteth/ETH, Chainlink ETH/USD and Chainlink USDC/USD - "0x4b7a328d4c03ea974acac4a4c5f092870afe707df88aa4c5d834f93d96894050", # vbETH/vbUSDC -> lltv 86%, oracle: Api3 ETH/USD and Api3 USDC/USD - "0x499a1b2827cff06de432a00b5e8c4509d4c2a7eafc638c0df6a09a8fa1c8d649", # vbWBTC/vbUSDC -> lltv 86%, oracle: Api3 BTC/USD and Api3 USDC/USD - "0x4bc9c84a5271f5196357c0ed18af783614851f23ac11652e78b9934e34baa5d1", # vbETH/vbUSDT -> lltv 86%, oracle: Api3 ETH/USD and Api3 USDT/USD - "0x9c95ce191559ba7652c7a2d74568590824c1166a2994fcef696b413c18efe7ee", # vbWBTC/vbUSDT -> lltv 86%, oracle: Api3 BTC/USD and Api3 USDT/USD - "0x1e74d36ffbda65b8a45d72754b349cdd5ce807c5fa814f91ba8e3cd27881c34b", # weETH/vbETH -> lltv 91.5%, oracle: Redstone weETH/ETH fundamental price - "0x22f9f76056c10ee3496dea6fefeaf2f98198ef597eda6f480c148c6d3aaa70db", # wstETH/vbETH -> lltv 91.5%, oracle: Redstone wstETH/ETH fundamental price - "0xc149387c455f7abf7a1f430ccc6639df55fcd366a2f5b055f611289ed1b8a956", # LBTC/vbUSDT -> lltv 86%, oracle: Redstone LBTC/BTC fundamental price and Redstone BTC/USD - "0xa0cd6b9d1fcc6baded4f7f8f93697dbe7f24f6e1fc22602a625c7a80b8e8e6ef", # LBTC/vbUSDC -> lltv 86%, oracle: Chainlink LBTC/USD and Chainlink USDC/USD - "0xcdaf57d98c2f75bffb8f0d3f7aa79bbacda4a479c47e316aab14af1ca6d85ffc", # yvUSDT/vbUSDC -> lltv 86%, oracle: yvUSDT vault rate. Chainlink USDT/USD and Chainlink USDC/USD - "0x6691cdcadd5d23ac68d2c1cf54dc97ab8242d2a888230de411094480252c2ed3", # yvUSDC/vbUSDT -> lltv 86%, oracle: yvUSDC vault rate. Chainlink USDC/USD and Chainlink USDT/USD - "0xd4ab732112fa9087c9c3c3566cd25bc78ee7be4f1b8bdfe20d6328debb818656", # vbWBTC/vbUSDT -> lltv 86%, oracle: Chainlink WBTC/USD - "0x9e03fc0dc3110daf28bc6bd23b32cb20b150a6da151856ead9540d491069db1c", # vbETH/vbUSDT -> lltv 86%, oracle: Chainlink ETH/USD - "0x08f67ef41398456dbc5ff72d43c8b6f7917abfd01498a9fc6c89dabe6eb78b8c", # yvvbETH/USDC -> lltv 77%, oracle: yearn vault exchange rate. Chainlink ETH/USD and Chainlink USDC/USD. - "0x3a22063bd258f3f75e3135cac4ec53435dfa5b47b3d5173bb8fd5278e6c1b305", # yvvbWBTC/USDC -> lltv 77%, oracle: yearn vault exchange rate. Chainlink WBTC/BTC, Chainlink BTC/USD and Chainlink USDC/USD. - "0xcfac40b9f06194a33d9526f73642f6849b908c2b6d8669ad9d2d4a3e7dcb017a", # yvAUSD/USDC -> lltv 86%, oracle: yearn vault exchange rate. Chainlink AUSD/USD and Chainlink USDC/USD. - "0xbeb2f6ad6de1a9eead3302ad57a0180f67d127a22e53fa29bc724147b96cb20d", # WBTC/AUSD -> lltv 86%, oracle: Chainlink WBTC/BTC, Chainlink BTC/USD and Chainlink AUSD/USD. - "0x02a77b251cb27b04b5ddab89c852bdc77ee85d359c082170389001d71571a967", # vbWETH/AUSD -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink AUSD/USD. - "0x0c909f9c866c4250cb2f15ef916b1eed5b1022b34ccd7ca947810011f5758c4f", # BTCK/AUSD -> lltv 86%, oracle: RedStone Price Feed for BTC. USD=AUSD. - "0xa6ce59291d90ae348b2fa956cc66f31df605a3304a9325e494c94e2cf5b0485a", # weETH/vbUSDT -> lltv 77%, oracle: RedStone weETH/ETH fundamental price, Chainlink ETH/USD and Chainlink USDT/USD. - "0x76e311d4b0e2e6ae88ad9bab18063452a6d39837d7104c430ff62457b91cb2cb", # weETH/vbUSDC -> lltv 77%, oracle: RedStone weETH/ETH fundamental price, Chainlink ETH/USD and Chainlink USDC/USD. - "0xbb4fb94ca819744df6a8f3932fffad47d31e8d76d3c48216878295c4cf588caf", # weETH/vbUSDT -> lltv 86%, oracle: RedStone weETH/ETH fundamental price, Chainlink ETH/USD and Chainlink USDT/USD. - "0x80e60fe453223b0f84a567724f88190bef708420d24397157067d424429783e9", # avKAT/KAT -> llt 77%, oracle ERC4626 avKAT/KAT vault rate - ], -} - -MARKETS_RISK_2 = { - Chain.MAINNET: [ - "0x85c7f4374f3a403b36d54cc284983b2b02bbd8581ee0f3c36494447b87d9fcab", # sUSDe/USDC -> lltv 91.5%, oracle: sUSDe vault - "0xc581c5f70bd1afa283eed57d1418c6432cbff1d862f94eaf58fdd4e46afbb67f", # USDe / USDC -> lltv 86%, same value asset but using hardcoded oracle - "0x5f8a138ba332398a9116910f4d5e5dcd9b207024c5290ce5bc87bc2dbd8e4a86", # ETH+/WETH -> lltv 94.5%, oracle: ETH+ / USD exchange rate adapter and Chainlink: ETH/USD. ETH+ token has monitoring. - "0x85ab69d50add7daa0934b5224889af0a882f2e3b4572d82c771dd0875f4eaa9b", # pufETH/WETH -> lltv 94.5%, oracle: pufETH vault exchange rate. Alike assets. - "0xbf02d6c6852fa0b8247d5514d0c91e6c1fbde9a168ac3fd2033028b5ee5ce6d0", # LBTC/USDC -> lltv 86%, oracle: Redstone LBTC / BTC Redstone redemption price feed and Chainlink BTC/USD. More info on LBTC/BTC: https://docs.redstone.finance/docs/data/lombard/#how-redstone-delivers-lbtcbtc-fundamental-price - "0xdb8938f97571aeab0deb0c34cf7e6278cff969538f49eebe6f4fc75a9a111293", # ETH+/USDC -> lltv 86%, oracle: ETH+ / USD exchange rate adapter and Chainlink: USDC/USD. ETH+ token has monitoring. - "0xe4cfbee9af4ad713b41bf79f009ca02b17c001a0c0e7bd2e6a89b1111b3d3f08", # tBTC/USDC -> lltv 77%, oracle: tBTC/USD UMA oracle that captures OEV and USDC/USD UMA oracle. - "0x550edc2e9fe71158ccfa7c478a31f4e60ef508d94ada3931dc2aee4f666f8f81", # yvUSDC-1/USDC -> lltv 91.5%, oracle: yvUSDC-1 vault rate. - "0x973e9dd45799efe8775417bcc420a3ab84a583587b2108985746e2fe201d0c83", # YFI/USDC -> lltv 77%, oracle: Chainlink YFI/USD and Chainlink USDC/USD. - "0xb8fef900b383db2dbbf4458c7f46acf5b140f26d603a6d1829963f241b82510e", # OETH/USDC -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink USDC/USD. OETH = ETH - "0xeb17955ea422baeddbfb0b8d8c9086c5be7a9cfdefb292119a102e981a30062e", # stcUSD/USDC -> lltv 91.5%, oracle: Ojo Yield Risk Engine stcUSD/cUSD Exchange Rate, RedStone Price Feed for cUSD_FUNDAMENTAL and Chainlink USDC/USD. - "0x2fb3713487c7812e7309935b034f40228841666f6b048faf31fd2110ae674f20", # PT-stcUSD-23JUL2026/USDC -> lltv 91.5%, oracle: OjoPTFeed oracle for stcUSD. RedStone Price Feed for cUSD_FUNDAMENTAL and Redstone USDC/USD v2. - "0x702b7ec7628de2622e51e1bb34a7e6ad9e95f3a25a2ed361e4ce621f23f5e642", # PT-cUSD-23JUL2026/USDC -> lltv 91.5%, oracle: OjoPTFeed oracle for cUSD. RedStone Price Feed for cUSD_FUNDAMENTAL and Redstone USDC/USD v2. - "0x729badf297ee9f2f6b3f717b96fd355fc6ec00422284ce1968e76647b258cf44", # syrupUSDC/USDC -> lltv 91.5%, oracle: syrupUSDC MaplePool vault rate. Oracle is using convertToAssets() to get the price but maple pool returns different amount, it should use convertToExitAssets() instead. - "0x61765602144e91e5ac9f9e98b8584eae308f9951596fd7f5e0f59f21cd2bf664", # weETH/USDC -> lltv 91.5%, oracle: redstone weETH/usdc exchange rate - "0xb7843fe78e7e7fd3106a1b939645367967d1f986c2e45edb8932ad1896450877", # XAUT/USDT -> lltv 77%, oracle: Chainlink XAUT/USD and Chainlink USDT/USD. - "0xc3b37a18d5b15f8e5b78bcdc014ffb3f22933bde4e5f6a36dedf36db87e68585", # WETH/RLUSD -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink RLUSD/USD. - "0x631e64ae8498821a5605bd3c14e253ffbf207f87411e2aeffc91a32a126cc13d", # WETH/PYUSD -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink PYUSD/USD. - "0xc0ae375fd761ff19b3f04de5534c0f1ec110f80e1c2ede27c42c1c43c3040394", # syrupUSDC/RLUSD -> lltv 91.5%, oracle: syrupUSDC MaplePool ERC4626 to USDC, chainlink USDC/USD and Chainlink RLUSD/USD. - "0xffd010618ed3cb39bb2c5de0e3e58d3d2ec9f52187a180f29723c31756a939bc", # cbBTC/RLUSD -> lltv 86%, oracle: chainlink cbBTC/USD and Chainlink RLUSD/USD. - "0xa128dddc761075df9a9a60689f3a41a989b245aad506352c509c0c3a76a9ec6b", # WBTC/RLUSD -> lltv 86%, oracle: Chainlink WBTC/BTC, Chainlink BTC/USD and Chainlink RLUSD/USD. - "0xea4bfb18df0ee6bffb7b3f0270899a8adb92ab6b684709634c8276128813cfd4", # weETH/RLUSD -> lltv 86%, oracle: chainlink weETH/ETH and chainlink ETH/USD and Chainlink RLUSD/USD. - "0x88abdf8693e663144c3544b9442e9b04520016d6ebc57aa76424c00ab1683c9d", # wstETH/RLUSD -> lltv 86%, oracle: Compound wstETH/ETH price feed, chainlink ETH/USD and Chainlink RLUSD/USD. - "0x48a0da254e4df7b1046baa5ef11beb7916203886ce153a07a6d28c5d63cf8fad", # sUSDe/RLUSD -> lltv 91.5%, oracle: sUSDe ERC4626 vault, chainlink USDe/USD and Chainlink RLUSD/USD. - "0xf5c5df23559b0fb56560a7578ea17d81e245153ba64b8132df026c9358864d27", # wstETH/PYUSD -> lltv 86%, oracle: Compound wstETH/ETH feed, chainlink ETH/USD and Chainlink PYUSD/USD. - "0xa5beccdffd156dfe8c0871f143648c512f0a34f37c8a4ae2ff31ebfe944641d1", # sUSDS/PYUSD -> lltv 94.5%, oracle: sUSDS ERC4626 vault, chainlink USDS/USD and Chainlink PYUSD/USD. - "0xc9629945524f3fde56c7e8854a6c3d48e76b9d97236abbe73c750fcc7aeb8501", # syrupUSDC/PYUSD -> lltv 91.5%, oracle: syrupUSDC MaplePool ERC4626 to USDC, chainlink USDC/USD and Chainlink PYUSD/USD. - "0x6a7e36eb088bd501d73f7ab4c5b8671358559341a78ce521c9e499dc0bc642b9", # LBTC/PYUSD -> lltv 86%, oracle: Redstone LBTC_FUNDAMENTAL, chainlink BTC/USD and Chainlink PYUSD/USD. - "0x85d59152eeeab7ca024804895b358868d8dd1e134171be400d7792d5604a212c", # weETH/PYUSD -> lltv 86%, oracle: chainlink weETH/ETH and chainlink ETH/USD and Chainlink PYUSD/USD. - "0x90ef0c5a0dc7c4de4ad4585002d44e9d411d212d2f6258e94948beecf8b4c0d5", # sUSDe/PYUSD -> lltv 91.5%, oracle: sUSDe ERC4626 vault, chainlink USDe/USD and Chainlink PYUSD/USD. - "0xcb12dcbc7c6c4f20ca1537a3cc1a41ec27501f85a3e322a710d9a16a88a28c0e", # PT-sUSDE-7MAY2026/PYUSD -> lltv 91.5%, oracle: Pendle oracle PT to USDe, chainlink USDe/USD and Chainlink PYUSD/USD. - "0xd8a8e6667f58aa9229e8979bd619742b1660ee856c200a93e407dbccb7222323", # cbBTC/PYUSD -> lltv 86%, oracle: chainlink cbBTC/USD and Chainlink PYUSD/USD. - "0x6d2fba32b8649d92432d036c16aa80779034b7469b63abc259b17678857f31c2", # wstETH/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2 — Api3 wstETH/USD + Api3 USDC/USD. - "0xba3ba077d9c838696b76e29a394ae9f0d1517a372e30fd9a0fc19c516fb4c5a7", # cbBTC/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, Api3 cbBTC/USD + Api3 USDC/USD. - "0x15bb2a6af0c909eed19fb1f2ceeead34ecbdcba626de752c6b09389ee14eec32", # kBTC/RLUSD -> lltv 86%, oracle: Chainlink BTC/USD and Chainlink RLUSD/USD. - "0xe51f9aaad25d0e755429cf77076b3c2d37cb1228ed81f8a5482f2102c220eef5", # kBTC/PYUSD -> lltv 86%, oracle: Chainlink BTC/USD and Chainlink PYUSD/USD. - "0xe3df58f9d3011b7481ff36b939fa5f8da642f34ea5792d25d3958dbf1efa26d7", # USD3/USDC -> lltv 91.5%, oracle: MorphoChainlinkOracleV2, USD3 ERC4626 vault rate (underlying USDC). No price feeds; USDC = $1. - "0xf8c5aa31ea6b2a068a9eddb46dd110cae57bf0f12be9583a3f9a818effecba89", # PT-USD3-17DEC2026/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, PendleSparkLinearDiscountOracle PT feed for PT-USD3. No quote feed (USDC = $1). Discount 30% per year. - ], - Chain.BASE: [ - "0x6aa81f51dfc955df598e18006deae56ce907ac02b0b5358705f1a28fcea23cc0", # wstETH/WETH -> lltv 96.5%, oracle: Chainlink wstETH-stETH Exchange Rate - "0x6600aae6c56d242fa6ba68bd527aff1a146e77813074413186828fd3f1cdca91", # cbETH/WETH -> lltv 96.5%, oracle: cbETH-ETH logocbETH-ETH Exchange Rate - "0x78d11c03944e0dc298398f0545dc8195ad201a18b0388cb8058b1bcb89440971", # weETH/WETH -> lltv 91.5%, oracle: Chainlink weETH / eETH Exchange Rate - "0xfd0895ba253889c243bf59bc4b96fd1e06d68631241383947b04d1c293a0cfea", # weETH/WETH -> lltv 94.5%, oracle: Chainlink weETH / eETH Exchange Rate - "0xdaa04f6819210b11fe4e3b65300c725c32e55755e3598671559b9ae3bac453d7", # AERO/USDC -> lltv 62.5%, oracle: Chainlink AERO/USD and Chainlink USDC/USD - "0x5189c48e1d333d250642a96b90dc926c53f897d8b8f9e8fea71a4b14e9053fde", # steakSUSDS/USDC -> lltv: 96.5%, oracle: Maker's SSR oracle for sUSDS / USDS and dummy oracle for USDC returns 1. USDS = USDC - "0xdba352d93a64b17c71104cbddc6aef85cd432322a1446b5b65163cbbc615cd0c", # cbETH/USDC -> lltv 86.5%, oracle: Chainlink cbETH/ETH and Chainlink ETH/USD and Chainlink USDC/USD -> but low liquidity - "0x7f90d72667171d72d10d62b5828d6a5ef7254b1e33718fe0c1f7dcf56dd1edc7", # bsdETH/WETH -> lltv 91.5%, oracle: bsdETH total supply. bsdETH token has internal monitoring. - "0x144bf18d6bf4c59602548a825034f73bf1d20177fc5f975fc69d5a5eba929b45", # wsuperOETHb/WETH -> lltv 91.5%, oracle: Vault exchange rate for wsuperOETHb/superOETHb, superOETHb=ETH. wsuperOETHb token has internal monitoring. - "0x67a66cbacb2fe48ec4326932d4528215ad11656a86135f2795f5b90e501eb538", # superOETHb/USDC -> lltv 77%, oracle: Chainlink ETH/USD and Chainlink USDC/USD, 1ETH=1superOETHb - "0xd4a903dc6d949519060c7707f9604fdc9772c046e05c2e3a8fce0bd7196e4109", # cbXRP/USDC -> lltv 62.5%, oracle: Chainlink XRP/USD - "0x9125d0fa03c3137166df68bcc72283477830de2a4a5536512374c573ad4583c3", # cbLTC/USDC -> lltv 62.5%, oracle: Chainlink LTC/USD - "0x30767836635facec1282e6ef4a5981406ed4e72727b3a63a3a72c74e8279a8d7", # LBTC/cbBTC -> lltv 94.5%, oracle: RedStone Price Feed for LBTC_FUNDAMENTAL: https://app.redstone.finance/app/feeds/base/lbtc_fundamental/ - "0x0b2df036bb06b49d893a8f5578cb5a31619f46d7a79cbf11783838204cfdf9e3", # YFI/USDC -> lltv 77%, oracle: Chainlink YFI/USD and Chainlink USDC/USD. - ], - Chain.KATANA: [ - "0xfe6cb1b88d8830a884f2459962f4b96ae6e38416af086b8ae49f5d0f7f9fc0cd", # POL/vbUSDC -> lltv 77%, oracle: Chainlink POL/USD and Chainlink USDC/USD - "0xdf0f160d591f02931e44010763f892a51a480257a5ff21c41ebff874b0c7d258", # BTCK/vbUSDT -> lltv 77%, oracle: Redstone BTC/USD - "0x0e9d558490ed0cd523681a8c51d171fd5568b04311d0906fec47d668fb55f5d9", # BTCK/vbUSDC -> lltv 77%, oracle: Redstone BTC/USD - "0x913c787d438ca1dab5f5485c2d2d6e2aa2dfee47a5f02edd11331ad25b219dcf", # KAT/vbUSDC -> lltv 62.5%, oracle: Chainlink KAT/USD and Chainlink USDC/USD. - "0x24e50037bacb39950700c00851d5260e61975fb79f252ae2adbf7fdbd8db7290", # KAT/vbUSDT -> lltv 62.5%, oracle: Chainlink KAT/USD and Chainlink USDT/USD. - "0x95f193f8f999718f3ce043249f12bfaea07458aae5343fc8d6355792cc17fa6c", # avKAT/vbUSDT -> lltv 62.5%, oracle: Custom oracle ERC4626 avKAT*exit-fee/KAT, Chainlink KAT/USD and Chainlink USDT/USD. - "0xbd48214a2f12e951da20ad0b8fd83b611c693b5bbaa280b68ba4075678f2a138", # avKAT/vbUSDC -> lltv 62.5%, oracle: Custom oracle ERC4626 avKAT*exit-fee/KAT, Chainlink KAT/USD and Chainlink USDC/USD. - "0x071ed2047610c7b33e1540e49fcc0a6852cb783cca0dd7dc428f32fd791a020f", # wstETH/AUSD -> lltv 86%, oracle: RedStone Price Feed for wstETH. USD=AUSD. - "0xa7cd449cc319d65be3d0926d6b6f599a8c3434bd95ba3e91bbf1ee5e80e72b56", # LBTC/vbUSDC -> lltv 86%, oracle: RedStone Price Feed for LBTC_FUNDAMENTAL and RedStone BTC/USD. USD=vbUSDC. - "0x2c4f26c76b4de51d3c9260c15a796cd2a35efab17786d0aa78ca2e638b0f8ba8", # yvvbUSDC/vbETH -> lltv 77%, oracle: yearn vault exchange rate. Chainlink ETH/USD and Chainlink USDC/USD. - "0x61fcb4d6d1534eedeb0e0bea361745f727d73f14569d231c4a2b39232b6b7312", # yvvbUSDT/vbWBTC -> lltv 77%, oracle: yearn vault exchange rate. Chainlink WBTC/USD and Chainlink USDT/USD. - "0x09c2ecea0580698a91be0cff2bad3648b00744453c14a9bfb6be5ca7b9950908", # PT-yvvbUSDC/vbUSDT -> lltv 86%, oracle: Pendle PT exchange rate(PT to asset) yvvbUSDC with yearn vault rate. Chainlink USDC/USD and Chainlink USDT/USD. - ], -} - -MARKETS_RISK_3 = { - Chain.MAINNET: [ - "0x0cd36e6ecd9d846cffd921d011d2507bc4c2c421929cec65205b3cd72925367c", # Curve TricryptoLLAMA LP / crvUSD -> collaterals: crvUSD, wstETH, tBTC. - "0x198132864e7974fb451dfebeb098b3b7e7e65566667fb1cf1116db4fb2ad23f9", # PT-LBTC-27MAR2025 / WBTC -> lltv 86%, oracle: Pendle PT exchange rate, readstone exchange rate LBTC/BTC and chainlink WBTC/BTC. - "0x8a0384fe5b1a68ff217845752287f432029b20754fbce577b6a5f8a80030a825", # PT-LBTC-26JUN2025 / WBTC -> lltv 91.5%, oracle: Pendle PT exchange rate, readstone exchange rate LBTC/BTC - "0xba761af4134efb0855adfba638945f454f0a704af11fc93439e20c7c5ebab942", # rsETH/WETH -> lltv 94.5%, oracle: origami rsETH/ETH which calls KELP_LRT_ORACLE.rsETHPrice(). Oracle address: https://etherscan.io/address/0x349A73444b1a310BAe67ef67973022020d70020d - "0xa0534c78620867b7c8706e3b6df9e69a2bc67c783281b7a77e034ed75cee012e", # ezETH/WETH -> lltv 94.5%, oracle: origami ezETH/ETH which calls renzoOracle()).calculateRedeemAmount(). It is hypothetical price, not the actual price. - "0x8e7cc042d739a365c43d0a52d5f24160fa7ae9b7e7c9a479bd02a56041d4cf77", # USR/USDC -> lltv 91.5%, oracle: USR/USD price aggregator which is checking reserves and defining max price as 1 - "0x97bb820669a19ba5fa6de964a466292edd67957849f9631eb8b830c382f58b7f", # MKR/USDC -> lltv 77%, oracle: Chainlink MKR/USD and Chainlink USDC/USD. - "0x718af3af39b183758849486340b69466e3e89b84b7884188323416621ee91cb7", # UNI/USDC -> lltv 62%, oracle: Chainlink UNI/USD and Chainlink USDC/USD. - "0x9c765f69d8a8e40d2174824bc5107d05d7f0d0f81181048c9403262aeb1ab457", # LINK/USDC -> lltv 77%, oracle: Chainlink LINK/USD and Chainlink USDC/USD. - "0xb7ad412532006bf876534ccae59900ddd9d1d1e394959065cb39b12b22f94ff5", # agETH/WETH -> lltv 91.5%, oracle: rsETH/ETH exchange rateainlink ETH/USD. Alike assets. - "0x1eda1b67414336cab3914316cb58339ddaef9e43f939af1fed162a989c98bc20", # USD0++/USDC -> lltv 96.5%, oracle: Naked USD0++ price feed adapter - "0xf9e56386e74f06af6099340525788eec624fd9c0fc0ad9a647702d3f75e3b6a9", # clUSD/USDC -> lltv 96.5%, oracle: Chainlink clUSD/USD - "0xd9e34b1eed46d123ac1b69b224de1881dbc88798bc7b70f504920f62f58f28cc", # wstUSR/USDC -> lltv 91.5%, oracle: wstUSR vault rate. USR/USD price aggregator which is checking reserves and defining max price as 1 - "0x53ed197357128ed96070e20ba9f5af4250cda6c67dcac5246876beb483f51303", # sDOLA/USDC -> lltv 91.5%, oracle: sDOLA vault rate. DOLA = USDC hardcoded oracle. - "0x7a7018e22a8bb2d08112eae9391e09f065a8ae7ae502c1c23dc96c21411a6efd", # EIGEN/USDC -> lltv 77%, oracle: Redstone EIGEN/USD. USD = USDC. - "0xce68c7aa336675e42bbc8eaa8b5ecc7ebd816bf8625b5316330c6ac2dabc4cf2", # SolvBTC/BTC -> lltv 94.5%, oracle: upgradeable MetaOracleDeviationTimelock with prime oracle morpho oracle with 1:1 hardcoded rate. same assets - "0xbbf7ce1b40d32d3e3048f5cf27eeaa6de8cb27b80194690aab191a63381d8c99", # siUSD/USDC -> lltv 91.5%, oracle: infinity accouting contract provides the price iUSD, vault rate siUSD to iUSD. usdc = 1 using dummy oracle. - "0xaac3ffcdf8a75919657e789fa72ab742a7bbfdf5bb0b87e4bbeb3c29bbbbb05c", # PT-siUSD-26MAR2026/USDC -> lltv 91.5%, oracle: ChainlinkOracleV2 — Pendle Chainlink-compatible PT feed, InfiniFi RT oracle (baseFeedTwo), dummy USDC feed (quote). - "0xdf034d0351a4c0af947e1a37ecd5ccbce60d72eac90de6fcad48c74e2869d14c", # PT-iUSD-25JUN2026/USDC -> lltv 91.5%, oracle: same stack as PT-siUSD row but Ojo PT Feed (Pendle-compatible) for PT leg; InfiniFi RT + dummy USDC. - "0xc6ae8e71e11ef511acee3f6cc6ad2af67b862877d459e3789905f537c85db5e3", # PT-sUSDE-25SEP2025/DAI -> lltv 91.5%, oracle: PendleSparkLinearDiscountOracle with linear discount oracle for sUSDE. No price oracle for DAI, USDe = DAI. - "0x27b9a0a5bfee98a31eb51e3850250d103a9f8e41673c782defc66aa943af0e65", # PT-srUSDe-2APR2026/USDC -> lltv 91.5%, oracle: Pendle PT exchange rate(PT to asset) srUSDe. USDC = 1 using dummy oracle. - ], - Chain.BASE: [ - "0x4944a1169bc07b441473b830308ffe5bb535c10a9f824e33988b60738120c48e", # LBTC/cbBTC -> lltv 91.5%, oracle: Custom moonwell oracle. Base feed is fetched from upgradeable oracle which uses 2 oracles. Primary oracle is redstone oracle, if the price changes more than 2% than it uses fallback oracle chainlink oracle. Chainlink didn't have an exchange rate feed. Redstone was the only provider for the LBTC reserves. - "0x214c2bf3c899c913efda9c4a49adff23f77bbc2dc525af7c05be7ec93f32d561", # wrsETH/WETH -> lltv 94.5%, oracle: Chainlink wrsETH/ETH exchange rate - "0x6a331b22b56c9c0ee32a1a7d6f852d2c682ea8b27a1b0f99a9c484a37a951eb7", # weETH/USDC -> lltv 77%, oracle: Chainlink weETH / eETH Exchange Rate and Chainlink ETH/USD and Chainlink USDC/USD - "0x52a2a376586d0775e3e80621facc464f6e96d81c8cb70fd461527dde195a079f", # LBTC/USDC -> lltv 86%, oracle: RedStone Price Feed for LBTC/BTC and Chainlink BTC/USD - "0xfdfecf85a4dd90a7637ae2aaf28b35061166f0e62bfc714c565eed9f7e959783", # cbXRP/USDC -> lltv 77%, oracle: Chainlink XRP/USD, Higher LLTV. - "0xdc69cf2caae7b7d1783fb5a9576dc875888afad17ab3d1a3fc102f741441c165", # rETH/WETH -> lltv 94.5%, oracle: Chainlink rETH/ETH, high risk oracle - "0x0103cbcd14c690f68a91ec7c84607153311e9954c94ac6eac06c9462db3fabb6", # rETH/EURC -> lltv 94.5%, oracle: Chainlink rETH/ETH, high risk oracle - "0x73527ddd796e6d4f48387adaae36f6f3d49d606d7f2a15eb0c931416a58875d8", # cbDOGE/USDC -> lltv 62.5%, oracle: Chainlink DOGE/USD - ], - Chain.KATANA: [ - "0xd8a93a4cd16f843c385391e208a9a9f2fd75aedfcca05e4810e5fbfcaa6baec6", # wsrUSD/vbUSDC -> lltv 91.5%, oracle: API3 wsrUSD/rUSD Exchange Rate, rUSD = vbUSDC. - "0xf7fc5cc82200ddf8f23188ddbd6727eda2c8bc41863e91fb767bbc6e4f71890e", # siUSD/vbUSDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, Chainlink SIUSD/USD; no quote feed (vbUSDC = USD). - "0xea8f588be62079a1ad874bf7c7217166b323e0fe8ea3e59b584430ed1b859ace", # stcUSD/vbUSDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, Chainlink STCAPUSD/CAPUSD exchange rate, Chainlink CAPUSD/USD and Chainlink USDC/USD. - ], - Chain.ARBITRUM: [ - "0x71c2954e00c8f72864600c9d1d1cd70fa15202c4294cd938d80add3be2eced26", # sUSDai/USDC -> lltv 91.5%, oracle: Chronicle sUSDai/USD and Chainlink USDC/USD. - "0x8147c63f3f6f5a0825c84bf2cb11443c72b609fa39cf9a362e3d4dc2c5ca76c4", # PT-USDai-19FEB2026/USDC -> lltv 91.5% oracle: MetaOracleDeviationTimelock by Steakhouse with primary oracle set to Pendle PT where USDai=USDC and backup oracle set to - "0x7717f1e04510390518811b3133ea47c298094ddd1d806ed8f8867d88c727bad7", # PT-sUSDai-19FEB2026/USDC -> lltv 86%, oracle: Pendle PT exchange rate(PT to asset) sUSDai with ERC4626 vault rate sUSDai to USDai. Chainlink oracle USDC/USD. - ], -} - -MARKETS_RISK_4 = { - Chain.MAINNET: [ - "0x3c83f77bde9541f8d3d82533b19bbc1f97eb2f1098bb991728acbfbede09cc5d", # rETH/WETH -> lltv 94.5%, oracle: gravita rETH/ETH. Can change owner and aggregator. - "0xe95187ba4e7668ab4434bbb17d1dfd7b87e878242eee3e73dac9fdb79a4d0d99", # EIGEN/USDC -> lltv 77%, oracle: Redstone EIGEN/USD. USD = USDC. - "0x444327b909aa41043cc4f20209eefb2fbb37f1c38ff9ca312374a4ecc3f0a871", # SolvBTC/USDC -> lltv 86%, oracle: chainlink BTC/USD and chainlink USDC/USD - "0x2287407f0f42ad5ad224f70e4d9da37f02770f79959df703d6cfee8afc548e0d", # STONE/WETH -> lltv 94.5%, centralization risk - "0xf78b7d3a62437f78097745a5e3117a50c56a02ec5f072cba8d988a129c6d4fb6", # beraSTONE/WETH -> lltv 91.5%, centralization risk. chainlink ETH/USD - "0xcacd4c39af872ddecd48b650557ff5bcc7d3338194c0f5b2038e0d4dec5dc022", # rswETH/WETH -> lltv 94.5%, unknown asset - "0x0eed5a89c7d397d02fd0b9b8e42811ca67e50ed5aeaa4f22e506516c716cfbbf", # pufETH/WETH -> lltv 86%, oracle: pufETH vault exchange rate. Low liquidity market. - "0x7e9c708876fa3816c46aeb08937b51aa0461c2af3865ecb306433db8a80b1d1b", # pufETH/USDC -> lltv 77%, oracle: pufETH vault exchange rate. Low liquidity market. - "0x514efda728a646dcafe4fdc9afe4ea214709e110ac1b2b78185ae00c1782cc82", # swBTC/WBTC -> lltv 94.5%, same asset, check swBTC liquidity before moving up - "0x20c488469064c8e2f892dab33e8c7a631260817f0db57f7425d4ef1d126efccb", # Re7wstETH/WETH -> lltv 91.5%, unknown asset - "0xd925961ad5df1d12f677ff14cf20bac37ea5ef3b325d64d5a9f4c0cc013a1d47", # stUSD/USDC -> lltv 96.5%, oracle: stUSD vault rate. Angle transmuter handles USDA -> USDC conversion. - "0xbf6687cb042a09451e66ebc11d7716c49fb8ccc75f484f7fab0eed6624bd5838", # mMEV/USDC -> lltv 91.5%, oracle: Midas price oracle mMEV/USD. More info at: https://docs.midas.app/defi-integration/price-oracle - "0x83b7ad16905809ea36482f4fbf6cfee9c9f316d128de9a5da1952607d5e4df5e", # csUSDL/USDC -> lltv 96.5%, oracle: wUSDL / USDL vault rate. - "0xe1b65304edd8ceaea9b629df4c3c926a37d1216e27900505c04f14b2ed279f33", # RLP/USDC -> lltv 86%, oracle: RLP oracle where the price is set manually, but must be in bounds. Owner of the proxy is multisig. - "0x8b1bc4d682b04a16309a8adf77b35de0c42063a7944016cfc37a79ccac0007b6", # slvlUSD/USDC -> lltv 91.5%, oracle: slvlUSD vault rate. lvlUSD = USDC - "0x95c28d447950ca6c8bbfd25fc05b80b1fd7a1cdd17a3610b4b3f1ffc8dc2e2ed", # mHYPER/USDC -> lltv 86%, oracle: MHyperCustomAggregatorFeed - "0xe83d72fa5b00dcd46d9e0e860d95aa540d5ec106da5833108a9f826f21f36f52", # AA_FalconXUSDC/USDC -> lltv 77%, oracle: TranchesChainlinkOracle virtual price of current tranches. - ], - Chain.BASE: [ - "0xff0f2bd52ca786a4f8149f96622885e880222d8bed12bbbf5950296be8d03f89", # USR/USDC -> lltv 91.5%, oracle: pyth USR/USD and pyth USDC/USD - ], - Chain.KATANA: [], -} - -MARKETS_RISK_5 = { - Chain.MAINNET: [ - "0xbfed072faee09b963949defcdb91094465c34c6c62d798b906274ef3563c9cac", # srUSD/USDC -> lltv 91.5%, oracle: saving rate module price. rUSD(USD) is underlying asset. rUSD = USDC hardcoded oracle. - "0x0f9563442d64ab3bd3bcb27058db0b0d4046a4c46f0acd811dacae9551d2b129", # sdeUSD/USDC -> lltv 91.5%, oracle: sdeUSD vault rate. Redstone oracle deusd/usd price, 24hour heartbeat, deviation 0.2%: https://app.redstone.finance/app/feeds/ethereum-mainnet/deusd_fundamental/ - ], - Chain.BASE: [], - Chain.KATANA: [ - "0x16ded80178992b02f7c467c373cfc9f4eee7f0356df672f6a768ec92b2ffdeff", # yUSD/vbUSDC -> lltv 86%, oracle: yUSD vault rate. yUSD = vbUSDC hardcoded oracle. - ], -} - -# Define base allocation tiers -ALLOCATION_TIERS = { - 1: 1.01, # Risk tier 1 max allocation # TODO: think about lowering this to 0.80 but some vaults use 100% allocation to one market - 2: 0.30, # Risk tier 2 max allocation - 3: 0.10, # Risk tier 3 max allocation - 4: 0.05, # Risk tier 4 max allocation - 5: 0.01, # Unknown market max allocation -} - -# Define max risk thresholds by risk level (each tier is level * 1.15, capped at 5.00) -MAX_RISK_THRESHOLDS = { - 1: 1.15, # Risk tier 1 max total risk - 2: 2.30, # Risk tier 2 max total risk - 3: 3.45, # Risk tier 3 max total risk - 4: 4.60, # Risk tier 4 max total risk - 5: 5.00, # Risk tier 5 max total risk -} - - -def get_market_allocation_threshold(market_risk_level: int, vault_risk_level: int) -> float: - """ - Get allocation threshold based on market and vault risk levels. - For higher vault risk levels, thresholds shift up (become more permissive). - For example, if vault risk level is 2, then market risk level 1 is 0.80, market risk level 2 is 0.30, etc. - - Args: - market_risk_level: Risk level of the market (1-5) - vault_risk_level: Risk level of the vault (1-5) - - Returns: - Allocation threshold as a decimal (0-1) - """ - # Shift market risk level down based on vault risk level - adjusted_risk = max(1, market_risk_level - (vault_risk_level - 1)) - return ALLOCATION_TIERS[adjusted_risk] - - -def get_chain_name(chain: Chain) -> str: - """Convert chain to name used in Morpho URLs.""" - if chain == Chain.MAINNET: - return "ethereum" - else: - return chain.name.lower() - - -def get_market_url(market: Dict[str, Any]) -> str: - """Generate URL for a Morpho market.""" - chain_id = market["collateralAsset"]["chain"]["id"] - chain = Chain.from_chain_id(chain_id) - return f"{MORPHO_URL}/{get_chain_name(chain)}/market/{market['marketId']}" - - -def get_vault_url(vault_data: Dict[str, Any]) -> str: - """Generate URL for a Morpho vault.""" - chain_id = vault_data["chain"]["id"] - chain = Chain.from_chain_id(chain_id) - return f"{MORPHO_URL}/{get_chain_name(chain)}/vault/{vault_data['address']}" - def bad_debt_alert( markets: List[Dict[str, Any]], @@ -469,9 +80,9 @@ def bad_debt_alert( continue # Alert if bad debt ratio exceeds threshold - if bad_debt / borrowed_tvl > BAD_DEBT_RATIO: + if is_bad_debt_excessive(bad_debt, borrowed_tvl): alerted_markets.add(market_id) - market_url = get_market_url(market) + market_url = get_market_url(market_id, chain) market_name = f"{market['collateralAsset']['symbol']}/{market['loanAsset']['symbol']}" message = ( @@ -483,7 +94,7 @@ def bad_debt_alert( send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) -def check_allocation_and_risk(vault_data): +def check_allocation_and_risk(vault_data: Dict[str, Any]) -> None: """ Check per-market allocation and total vault risk level. Sends a consolidated alert if any markets exceed allocation thresholds. @@ -494,19 +105,10 @@ def check_allocation_and_risk(vault_data): return vault_name = vault_data["name"] - vault_url = get_vault_url(vault_data) chain = Chain.from_chain_id(vault_data["chain"]["id"]) - # Find vault in VAULTS_BY_CHAIN to get risk level vault_address = vault_data["address"] - risk_level = None - for vault in VAULTS_BY_CHAIN[chain]: - if vault[1].lower() == vault_address.lower(): - risk_level = vault[2] - break - - if risk_level is None: - # Throw error if vault not found in config - raise ValueError(f"Vault {vault_address} not found in VAULTS_BY_CHAIN config") + vault_url = get_vault_url(vault_address, chain) + risk_level = get_vault_config(vault_address, chain, version=1).risk_level total_risk_level = 0.0 allocation_violations: list[str] = [] @@ -523,36 +125,18 @@ def check_allocation_and_risk(vault_data): if market_supply == 0: logger.info("Skipping market %s has 0 supply assets", market_id) continue - allocation_ratio = min(market_supply / total_assets, 1.0) # prevent allocation ratio from exceeding 100% - - # Determine market risk level - if market_id in MARKETS_RISK_1[chain]: - market_risk_level = 1 - elif market_id in MARKETS_RISK_2[chain]: - market_risk_level = 2 - elif market_id in MARKETS_RISK_3[chain]: - market_risk_level = 3 - elif market_id in MARKETS_RISK_4[chain]: - market_risk_level = 4 - else: - market_risk_level = 5 + market_risk_level = get_market_risk_level(market_id, chain) + assessment = assess_exposure(market_supply / total_assets, market_risk_level, risk_level) - allocation_threshold = get_market_allocation_threshold(market_risk_level, risk_level) - risk_multiplier = market_risk_level - - if allocation_ratio > allocation_threshold: - market_url = get_market_url(market) + if assessment.allocation_exceeded: + market_url = get_market_url(market_id, chain) market_name = f"{market['collateralAsset']['symbol']}/{market['loanAsset']['symbol']}" allocation_violations.append( f"- [{market_name}]({market_url}) (risk {market_risk_level}): " - f"{allocation_ratio:.1%} (max: {allocation_threshold:.1%})" + f"{assessment.allocation_ratio:.1%} (max: {assessment.allocation_threshold:.1%})" ) - # Calculate weighted risk score for each market allocation - # risk_multiplier: market risk tier (1-5, higher = riskier) - # allocation_ratio: percentage of vault's assets in this market - # total_risk_level: sum of (risk_tier * allocation) across all markets - total_risk_level += risk_multiplier * allocation_ratio + total_risk_level += assessment.risk_score # Send consolidated allocation alert if any markets exceed thresholds if allocation_violations: @@ -576,54 +160,9 @@ def check_allocation_and_risk(vault_data): send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) -def is_yv_collateral_vault(vault_address: str, chain: Chain) -> bool: - """ - Check if a vault is used as YV collateral by looking in the asset-based config. - - Args: - vault_address: Address of the vault to check - chain: Chain the vault is on - - Returns: - True if vault is used as YV collateral - """ - if chain not in VAULTS_WITH_YV_COLLATERAL_BY_ASSET: - return False - - # Check all asset groups for this chain - for asset_address, vaults in VAULTS_WITH_YV_COLLATERAL_BY_ASSET[chain].items(): - for vault_name, vault_addr in vaults: - if vault_addr.lower() == vault_address.lower(): - return True - - return False - - -def get_yv_collateral_vaults_by_asset(chain: Chain) -> Dict[str, List[str]]: - """ - Get YV collateral vaults organized by asset address. - - Args: - chain: Chain to get vaults for - - Returns: - Dictionary with asset address as key and list of vault addresses as value - """ - result = {} - - if chain not in VAULTS_WITH_YV_COLLATERAL_BY_ASSET: - return result - - for asset_address, vaults in VAULTS_WITH_YV_COLLATERAL_BY_ASSET[chain].items(): - vault_addresses = [vault[1] for vault in vaults] # Extract addresses from [name, address] pairs - result[asset_address.lower()] = vault_addresses - - return result - - def group_vaults_by_chain(vaults_data: List[Dict[str, Any]]) -> Dict[Chain, List[Dict[str, Any]]]: """Group vaults by their chain.""" - vaults_by_chain = {} + vaults_by_chain: Dict[Chain, List[Dict[str, Any]]] = {} for vault_data in vaults_data: chain = Chain.from_chain_id(vault_data["chain"]["id"]) if chain not in vaults_by_chain: @@ -653,24 +192,70 @@ def find_yv_vaults_for_asset( def calculate_combined_metrics(asset_yv_vaults: List[Dict[str, Any]]) -> tuple[float, float, List[str]]: - """Calculate combined total assets, liquidity, and vault names for a group of vaults.""" + """Calculate combined v1/v2 total assets, liquidity, and vault names.""" combined_total_assets = 0 - combined_liquidity = 0 + unshared_liquidity = 0.0 + liquidity_sources: Dict[str, List[float]] = {} vault_names = [] for vault in asset_yv_vaults: - total_assets = vault["state"]["totalAssetsUsd"] or 0 - liquidity = vault["liquidity"]["usd"] or 0 + if vault.get("__typename") == "VaultV2": + total_assets = vault.get("totalAssetsUsd") or 0 + liquidity = vault.get("liquidityUsd") or 0 + vault_name = f"{vault['name']} (V2)" + else: + total_assets = vault["state"]["totalAssetsUsd"] or 0 + liquidity = vault["liquidity"]["usd"] or 0 + vault_name = vault["name"] # Only include vaults with meaningful assets (>= 10k) if total_assets >= 10_000: combined_total_assets += total_assets - combined_liquidity += liquidity - vault_names.append(vault["name"]) + vault_names.append(vault_name) + sources = _get_vault_liquidity_sources(vault, liquidity) + source_total = sum(source[1] for source in sources) + scale = min(liquidity / source_total, 1.0) if source_total else 0.0 + attributed_liquidity = source_total * scale + unshared_liquidity += max(liquidity - attributed_liquidity, 0) + + for source_key, source_liquidity, source_cap in sources: + source_liquidity *= scale + if source_key in liquidity_sources: + liquidity_sources[source_key][0] += source_liquidity + liquidity_sources[source_key][1] = min(liquidity_sources[source_key][1], source_cap) + else: + liquidity_sources[source_key] = [source_liquidity, source_cap] + + shared_liquidity = sum(min(liquidity, cap) for liquidity, cap in liquidity_sources.values()) + combined_liquidity = unshared_liquidity + shared_liquidity return combined_total_assets, combined_liquidity, vault_names +def _get_vault_liquidity_sources(vault: Dict[str, Any], reported_liquidity: float) -> List[tuple[str, float, float]]: + """Return market-backed liquidity contributions as (market, amount, market cap).""" + if vault.get("__typename") == "VaultV2": + idle_liquidity = min(float(vault.get("idleAssetsUsd") or 0), reported_liquidity) + market = (vault.get("liquidityData") or {}).get("market") + market_liquidity = (market or {}).get("state", {}).get("liquidityAssetsUsd") + if market is None or market_liquidity is None: + return [] + adapter_liquidity = max(reported_liquidity - idle_liquidity, 0) + return [(market["marketId"].lower(), adapter_liquidity, float(market_liquidity))] + + sources = [] + for allocation in vault.get("state", {}).get("allocation") or []: + market = allocation.get("market") or {} + market_liquidity = market.get("state", {}).get("liquidityAssetsUsd") + if allocation.get("withdrawQueueIndex") is None or market.get("collateralAsset") is None: + continue + if market_liquidity is None: + continue + supplied = float(allocation.get("supplyAssetsUsd") or 0) + sources.append((market["marketId"].lower(), min(supplied, float(market_liquidity)), float(market_liquidity))) + return sources + + def parse_lltv(lltv: str | int | None) -> float: """Convert Morpho's WAD-scaled LLTV into a decimal ratio.""" if lltv is None: @@ -691,13 +276,30 @@ def get_yv_collateral_price_shock(lltv: str | int | None) -> float: return YV_COLLATERAL_FALLBACK_PRICE_SHOCK -def get_yv_collateral_liquidity_by_asset(chain: Chain, chain_vaults: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: +def get_yv_collateral_liquidity_by_asset( + chain: Chain, + chain_vaults: List[Dict[str, Any]], + chain_v2_vaults: List[Dict[str, Any]], +) -> Dict[str, Dict[str, Any]]: """Build withdrawable liquidity groups for Yearn-vault collateral underlying assets.""" - yv_vaults_by_asset = get_yv_collateral_vaults_by_asset(chain) - liquidity_by_asset = {} - - for asset_address, yv_vault_addresses in yv_vaults_by_asset.items(): - asset_yv_vaults = find_yv_vaults_for_asset(chain_vaults, asset_address, yv_vault_addresses) + yv_vaults_by_asset = get_collateral_vaults_by_asset(chain, version=1) + yv_v2_vaults_by_asset = get_collateral_vaults_by_asset(chain, version=2) + liquidity_by_asset: Dict[str, Dict[str, Any]] = {} + + asset_addresses = yv_vaults_by_asset.keys() | yv_v2_vaults_by_asset.keys() + for asset_address in asset_addresses: + asset_yv_vaults = find_yv_vaults_for_asset( + chain_vaults, + asset_address, + [vault.address for vault in yv_vaults_by_asset.get(asset_address, [])], + ) + asset_yv_vaults.extend( + find_yv_vaults_for_asset( + chain_v2_vaults, + asset_address, + [vault.address for vault in yv_v2_vaults_by_asset.get(asset_address, [])], + ) + ) if not asset_yv_vaults: continue @@ -709,13 +311,12 @@ def get_yv_collateral_liquidity_by_asset(chain: Chain, chain_vaults: List[Dict[s vault_names, ) = calculate_combined_metrics(asset_yv_vaults) - if combined_total_assets < YV_COLLATERAL_MIN_GROUP_ASSETS_USD: + if combined_total_assets < 10_000: logger.info( - "Skipping %s YV collateral liquidity group: total assets $%s below threshold", + "%s YV collateral liquidity group has only $%s total assets; retaining zero/low liquidity coverage", asset_symbol, f"{combined_total_assets:,.2f}", ) - continue asset_key = asset_address.lower() group_data = { @@ -724,7 +325,7 @@ def get_yv_collateral_liquidity_by_asset(chain: Chain, chain_vaults: List[Dict[s "combined_total_assets": combined_total_assets, "combined_liquidity": combined_liquidity, "vault_names": vault_names, - "vault_count": len(asset_yv_vaults), + "vault_count": len(vault_names), } if asset_key in liquidity_by_asset: existing = liquidity_by_asset[asset_key] @@ -741,12 +342,12 @@ def get_yv_collateral_liquidity_by_asset(chain: Chain, chain_vaults: List[Dict[s else: liquidity_by_asset[asset_key] = group_data - liquidity_ratio = combined_liquidity / combined_total_assets + liquidity_ratio = combined_liquidity / combined_total_assets if combined_total_assets else 0 logger.info( "YV collateral liquidity group %s on %s: %s vaults, $%s total assets, $%s liquidity (%s)", asset_symbol, chain.name, - len(asset_yv_vaults), + len(vault_names), f"{combined_total_assets:,.2f}", f"{combined_liquidity:,.2f}", f"{liquidity_ratio:.1%}", @@ -757,7 +358,7 @@ def get_yv_collateral_liquidity_by_asset(chain: Chain, chain_vaults: List[Dict[s def collect_yv_collateral_markets( chain: Chain, - chain_vaults: List[Dict[str, Any]], + configured_markets: List[Dict[str, Any]], liquidity_by_asset: Dict[str, Dict[str, Any]], ) -> Dict[str, tuple[Dict[str, Any], Dict[str, Any]]]: """Collect configured direct Yearn vault collateral markets.""" @@ -769,38 +370,33 @@ def collect_yv_collateral_markets( } markets: Dict[str, tuple[Dict[str, Any], Dict[str, Any]]] = {} - for vault_data in chain_vaults: - for allocation in vault_data["state"]["allocation"]: - market = allocation.get("market") or {} - if market.get("collateralAsset") is None: - continue - - market_id = market["marketId"] - asset_address = market_to_asset.get(market_id.lower()) - if asset_address is None: - continue - - market_state = market.get("state") or {} - borrow_usd = market_state.get("borrowAssetsUsd") or 0 - if borrow_usd < YV_COLLATERAL_MIN_BORROW_USD: - continue - - liquidity_group = liquidity_by_asset.get(asset_address) - if liquidity_group is None: - logger.warning( - "Skipping configured YV collateral market %s on %s: no liquidity group for asset %s", - market_id, - chain.name, - asset_address, - ) - continue - - markets[market_id] = (market, liquidity_group) + for market in configured_markets: + collateral_asset = market.get("collateralAsset") + if collateral_asset is None or collateral_asset.get("chain", {}).get("id") != chain.chain_id: + continue + + market_id = market["marketId"] + asset_address = market_to_asset.get(market_id.lower()) + if asset_address is None: + continue + + borrow_usd = market.get("state", {}).get("borrowAssetsUsd") or 0 + if borrow_usd < YV_COLLATERAL_MIN_BORROW_USD: + continue + + liquidity_group = liquidity_by_asset.get(asset_address) + if liquidity_group is None: + raise MorphoMonitoringError( + f"No {chain.name} liquidity group for configured YV collateral market {market_id} " + f"and asset {asset_address}" + ) + + markets[market_id] = (market, liquidity_group) return markets -def get_markets_collateral_at_risk_usd(market_shocks: Dict[str, float], chain: Chain) -> Dict[str, float] | None: +def get_markets_collateral_at_risk_usd(market_shocks: Dict[str, float], chain: Chain) -> Dict[str, float]: """Fetch Morpho collateral at risk for many markets in a single aliased request. Args: @@ -808,7 +404,7 @@ def get_markets_collateral_at_risk_usd(market_shocks: Dict[str, float], chain: C chain: Chain the markets live on. Returns: - Mapping of market id to collateral-at-risk USD at its target price, or None if the request fails. + Mapping of market id to collateral-at-risk USD at its target price. """ if not market_shocks: return {} @@ -835,20 +431,7 @@ def get_markets_collateral_at_risk_usd(market_shocks: Dict[str, float], chain: C + "\n".join(query_fields) + "\n}" ) - json_data = {"query": query, "variables": variables} - - try: - response = request_with_retry("post", API_URL, json=json_data) - except requests.RequestException as e: - logger.error("Failed to fetch collateral at risk on %s: %s", chain.name, e) - return None - - data = response.json() - if "errors" in data: - logger.error("GraphQL error fetching collateral at risk on %s: %s", chain.name, data) - return None - - response_data = data.get("data") or {} + response_data = execute_graphql(query, variables, f"collateral at risk on {chain.name}") collateral_at_risk_by_market: Dict[str, float] = {} for alias, market_id in alias_to_market.items(): market_data = response_data.get(alias) or {} @@ -865,10 +448,12 @@ def get_markets_collateral_at_risk_usd(market_shocks: Dict[str, float], chain: C def check_yv_collateral_market_liquidity( - chain: Chain, chain_vaults: List[Dict[str, Any]], liquidity_by_asset: Dict[str, Dict[str, Any]] + chain: Chain, + configured_markets: List[Dict[str, Any]], + liquidity_by_asset: Dict[str, Dict[str, Any]], ) -> None: """Alert only when underlying liquidity cannot cover risky direct YV collateral liquidations.""" - markets = collect_yv_collateral_markets(chain, chain_vaults, liquidity_by_asset) + markets = collect_yv_collateral_markets(chain, configured_markets, liquidity_by_asset) if not markets: return @@ -877,8 +462,6 @@ def check_yv_collateral_market_liquidity( for market_id, (market, liquidity_group) in markets.items() } collateral_at_risk_by_market = get_markets_collateral_at_risk_usd(market_shocks, chain) - if collateral_at_risk_by_market is None: - return checks_by_asset: Dict[str, Dict[str, Any]] = {} for market_id, (market, liquidity_group) in markets.items(): @@ -913,7 +496,7 @@ def check_yv_collateral_market_liquidity( ) group_check["total_collateral_at_risk"] += collateral_at_risk group_check["total_required_liquidity"] += required_liquidity - market_url = get_market_url(market) + market_url = get_market_url(market_id, chain) market_name = f"{collateral_asset['symbol']}/{loan_asset['symbol']}" group_check["market_lines"].append( f"- [{market_name}]({market_url}): ${collateral_at_risk:,.2f} at risk " @@ -956,73 +539,65 @@ def check_individual_liquidity_for_chain(chain: Chain, chain_vaults: List[Dict[s """Check individual liquidity for non-YV collateral vaults on a specific chain.""" for vault_data in chain_vaults: vault_address = vault_data["address"] - if not is_yv_collateral_vault(vault_address, chain): + if not is_collateral_vault(vault_address, chain, version=1): check_low_liquidity(vault_data) -def check_low_liquidity_combined(vaults_data: List[Dict[str, Any]]) -> None: +def check_low_liquidity_combined( + vaults_data: List[Dict[str, Any]], + v2_vaults_data: List[Dict[str, Any]], + configured_markets: List[Dict[str, Any]], +) -> None: """ - Check liquidity for vaults, with special logic for VAULTS_WITH_YV_COLLATERAL_BY_ASSET. + Check individual and combined collateral-strategy vault liquidity. For YV collateral vaults, combine all vaults with the same asset and check if combined liquidity can cover direct Yearn-vault collateral liquidations at risk. """ # Group vaults by chain for processing vaults_by_chain = group_vaults_by_chain(vaults_data) + v2_vaults_by_chain = group_vaults_by_chain(v2_vaults_data) # Process each chain separately - for chain, chain_vaults in vaults_by_chain.items(): + for chain in vaults_by_chain.keys() | v2_vaults_by_chain.keys(): + chain_vaults = vaults_by_chain.get(chain, []) + chain_v2_vaults = v2_vaults_by_chain.get(chain, []) # Check market-aware YV collateral unwind liquidity - yv_liquidity_by_asset = get_yv_collateral_liquidity_by_asset(chain, chain_vaults) - check_yv_collateral_market_liquidity(chain, chain_vaults, yv_liquidity_by_asset) + yv_liquidity_by_asset = get_yv_collateral_liquidity_by_asset(chain, chain_vaults, chain_v2_vaults) + check_yv_collateral_market_liquidity(chain, configured_markets, yv_liquidity_by_asset) # Check individual liquidity for non-YV collateral vaults check_individual_liquidity_for_chain(chain, chain_vaults) -def check_low_liquidity(vault_data): +def check_low_liquidity(vault_data: Dict[str, Any]) -> None: """ Send telegram message if low liquidity is detected. """ vault_name = vault_data["name"] - vault_url = get_vault_url(vault_data) total_assets = vault_data["state"]["totalAssetsUsd"] - liquidity = vault_data["liquidity"]["usd"] + liquidity = vault_data["liquidity"]["usd"] or 0 chain = Chain.from_chain_id(vault_data["chain"]["id"]) + vault_url = get_vault_url(vault_data["address"], chain) - # Return early if total_assets is None or 0 or less than 10k - if not total_assets or total_assets < 10_000: + if not total_assets or not is_low_liquidity(total_assets, liquidity): return - # Default liquidity to 0 if it's None - liquidity = liquidity or 0 - liquidity_ratio = liquidity / total_assets - - # standard liquidity check (YV collateral vaults are handled separately) - if liquidity_ratio < LIQUIDITY_THRESHOLD: - message = ( - f"⚠️ Low liquidity in [{vault_name}]({vault_url}) on {chain.name}\n" - f"💰 Liquidity: ${liquidity:,.2f} ({liquidity_ratio:.1%} of ${total_assets:,.2f})\n" - f"📊 Min threshold: {LIQUIDITY_THRESHOLD:.1%}\n" - ) - send_alert(Alert(AlertSeverity.LOW, message, PROTOCOL)) - - -def main() -> None: - """ - Check markets for low liquidity, high allocation and bad debt. - Send telegram message if data cannot be fetched. - """ - logger.info("Checking Morpho markets...") + message = format_low_liquidity_message( + vault_name, + vault_url, + chain, + total_assets, + liquidity, + LIQUIDITY_THRESHOLD, + ) + send_alert(Alert(AlertSeverity.LOW, message, PROTOCOL)) - # Collect all vault addresses from all chains - vault_addresses = [] - for chain, vaults in VAULTS_BY_CHAIN.items(): - vault_addresses.extend([vault[1] for vault in vaults]) - query = """ - query GetVaults($addresses: [String!]!) { +_VAULTS_QUERY = """ + query GetVaults($addresses: [String!]!, $v2Addresses: [String!]!, $marketIds: [String!]!) { vaults(where: { address_in: $addresses } ) { items { + __typename address name chain { @@ -1041,6 +616,7 @@ def main() -> None: allocation { supplyCap supplyAssetsUsd + withdrawQueueIndex pendingSupplyCapUsd pendingSupplyCapValidAt market { @@ -1061,6 +637,7 @@ def main() -> None: utilization borrowAssetsUsd supplyAssetsUsd + liquidityAssetsUsd } badDebt { underlying @@ -1071,38 +648,110 @@ def main() -> None: } } } + vaultV2s(first: 200, where: { address_in: $v2Addresses }) { + items { + __typename + address + name + chain { id } + asset { address symbol name } + totalAssetsUsd + idleAssetsUsd + liquidityUsd + liquidityData { + __typename + ... on MarketV1LiquidityData { + market { + marketId + state { liquidityAssetsUsd } + } + } + } + } + } + markets(first: 200, where: { uniqueKey_in: $marketIds }) { + items { + marketId + lltv + loanAsset { address symbol } + collateralAsset { + address + symbol + chain { id } + } + state { borrowAssetsUsd } + } + } } - """ +""" - json_data = {"query": query, "variables": {"addresses": vault_addresses}} - try: - response = request_with_retry("post", API_URL, json=json_data) - except requests.RequestException as e: - send_error_message( - f"🚨 Problem with fetching data for Morpho markets: {e.response.status_code} 🚨", - PROTOCOL, - ) - return +def get_configured_v2_collateral_vault_addresses() -> List[str]: + """Return every configured Vault V2 used by a YV-collateral strategy.""" + return [vault.address for _, vault in iter_vaults(VAULTS_V2_BY_CHAIN) if vault.collateral_asset is not None] - data = response.json() - if "errors" in data: - send_error_message( - f"🚨 GraphQL error when fetching Morpho data. Response code: {response.status_code} 🚨", - PROTOCOL, - ) - return - vaults_data = data.get("data", {}).get("vaults", {}).get("items", []) - if len(vaults_data) == 0: - send_error_message( - "🚨 No vaults data found for Morpho markets 🚨", - PROTOCOL, - ) - return +def get_configured_yv_collateral_market_ids() -> List[str]: + """Return every direct YV-collateral Morpho market configured for coverage checks.""" + return [ + market_id + for markets_by_asset in YV_COLLATERAL_MARKETS_BY_ASSET.values() + for market_ids in markets_by_asset.values() + for market_id in market_ids + ] + + +def fetch_configured_vaults() -> tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]: + """Fetch configured v1 vaults, v2 collateral vaults, and direct collateral markets.""" + vault_addresses = [vault.address for _, vault in iter_vaults(VAULTS_V1_BY_CHAIN)] + v2_vault_addresses = get_configured_v2_collateral_vault_addresses() + market_ids = get_configured_yv_collateral_market_ids() + + data = execute_graphql( + _VAULTS_QUERY, + { + "addresses": vault_addresses, + "v2Addresses": v2_vault_addresses, + "marketIds": market_ids, + }, + "configured vaults and collateral markets", + ) + + vaults_data = data.get("vaults", {}).get("items", []) + found_v1_addresses = {vault["address"] for vault in vaults_data} + require_configured_keys(vault_addresses, found_v1_addresses, "Vault V1 addresses") + + v2_vaults_data = data.get("vaultV2s", {}).get("items", []) + found_v2_addresses = {vault["address"] for vault in v2_vaults_data} + require_configured_keys(v2_vault_addresses, found_v2_addresses, "YV-collateral Vault V2 addresses") + + markets_data = data.get("markets", {}).get("items", []) + found_market_ids = {market["marketId"] for market in markets_data} + require_configured_keys(market_ids, found_market_ids, "YV-collateral market IDs") + + return vaults_data, v2_vaults_data, markets_data + + +def get_active_vault_markets(vault_data: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return non-idle markets with a cap and more than $10k supplied.""" + vault_markets = [] + for allocation in vault_data["state"]["allocation"]: + market_supply_usd = allocation.get("market", {}).get("state", {}).get("supplyAssetsUsd") + if int(allocation.get("supplyCap", 0)) == 0 or (market_supply_usd or 0) <= 1e4: + continue + market = allocation["market"] + if market["collateralAsset"] is not None: + vault_markets.append(market) + return vault_markets + + +def main() -> None: + """Check markets for low liquidity, high allocation, risk, and bad debt.""" + logger.info("Checking Morpho markets...") + vaults_data, v2_vaults_data, configured_markets = fetch_configured_vaults() # Check combined liquidity for all vaults (handles YV collateral grouping) - check_low_liquidity_combined(vaults_data) + check_low_liquidity_combined(vaults_data, v2_vaults_data, configured_markets) alerted_markets: set[str] = set() @@ -1110,20 +759,10 @@ def main() -> None: # Check per-market allocation and total risk level check_allocation_and_risk(vault_data) - # Check bad debt for each market in the vault - vault_markets = [] - for allocation in vault_data["state"]["allocation"]: - market_supply_usd = allocation.get("market", {}).get("state", {}).get("supplyAssetsUsd") - if int(allocation.get("supplyCap", 0)) > 0 and (market_supply_usd or 0) > 1e4: # skip low value markets - market = allocation["market"] - if market["collateralAsset"] is not None: - # market without collateral asset is idle asset - vault_markets.append(market) - vault_name = vault_data["name"] - vault_url = get_vault_url(vault_data) chain = Chain.from_chain_id(vault_data["chain"]["id"]) - bad_debt_alert(vault_markets, vault_name, vault_url, chain, alerted_markets) + vault_url = get_vault_url(vault_data["address"], chain) + bad_debt_alert(get_active_vault_markets(vault_data), vault_name, vault_url, chain, alerted_markets) if __name__ == "__main__": diff --git a/protocols/morpho/markets_v2.py b/protocols/morpho/markets_v2.py index e73dc87..8d127d9 100644 --- a/protocols/morpho/markets_v2.py +++ b/protocols/morpho/markets_v2.py @@ -9,38 +9,42 @@ 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 runs the existing v1 risk-tier scoring (``MARKETS_RISK_*`` + - ``ALLOCATION_TIERS`` + ``MAX_RISK_THRESHOLDS``). + GraphQL, and applies the shared risk-tier policy from ``risk.py``. -Bad debt is pulled per market from the same GraphQL endpoint v1 uses. -Liquidity monitoring is deferred to phase 2 (see TODO at bottom). +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. """ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional -import requests from web3 import Web3 from protocols.morpho._shared import ( - API_URL, - SUPPORTED_CHAINS, - VAULTS_V2_BY_CHAIN, + PROTOCOL, MarketMetrics, + MorphoV2MonitoringError, + execute_graphql, fetch_market_metrics, + format_low_liquidity_message, get_market_url, get_vault_url, + require_configured_keys, +) +from protocols.morpho.config import ( + VAULTS_V1_BY_CHAIN, + VAULTS_V2_BY_CHAIN, + get_vault_query_config, + is_collateral_vault, ) -from protocols.morpho.markets import ( - BAD_DEBT_RATIO, - MARKETS_RISK_1, - MARKETS_RISK_2, - MARKETS_RISK_3, - MARKETS_RISK_4, - MARKETS_RISK_5, +from protocols.morpho.risk import ( + LIQUIDITY_THRESHOLD, MAX_RISK_THRESHOLDS, - VAULTS_BY_CHAIN, - get_market_allocation_threshold, + assess_exposure, + get_market_risk_level, + is_low_liquidity, ) from utils.abi import load_abi from utils.alert import Alert, AlertSeverity, send_alert @@ -51,11 +55,9 @@ write_last_value_to_file, ) from utils.chains import Chain -from utils.http_client import request_with_retry from utils.logger import get_logger from utils.web3_wrapper import ChainManager, Web3Client -PROTOCOL = "morpho" logger = get_logger("morpho.markets_v2") ABI_VAULT_V2 = load_abi("protocols/morpho/abi/vault_v2.json") @@ -64,7 +66,6 @@ ADAPTER_KIND_MARKET = "MorphoMarketV1AdapterV2" ADAPTER_KIND_VAULT = "MorphoVaultV1Adapter" -ADAPTER_KIND_UNKNOWN = "Unknown" # Cache tag for "this wrapped v1 vault has already been flagged as unmonitored" — # without this, every hourly run would re-spam the channel. @@ -87,6 +88,7 @@ class V2Vault: 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) @@ -101,6 +103,15 @@ class AdapterInfo: expected_supply_assets: List[int] = field(default_factory=list) # parallel to market_ids +@dataclass(frozen=True) +class MarketAssessment: + """Risk contribution and optional alert lines for one Morpho market.""" + + risk_score: float + allocation_violation: Optional[str] = None + bad_debt_alert: Optional[str] = None + + # ---------------------------------------------------------------------------- # Vault state fetch (static list → GraphQL) # ---------------------------------------------------------------------------- @@ -117,6 +128,7 @@ class AdapterInfo: owner { address } asset { address symbol } totalAssetsUsd + liquidityUsd adapters { items { address type assetsUsd } } @@ -131,59 +143,43 @@ def discover_v2_vaults_by_chain() -> Dict[Chain, List[V2Vault]]: Issues a single GraphQL ``vaultV2s(where: { address_in })`` query, then joins the result back to the static list to inherit the configured risk - level. Vaults missing from the GraphQL response are logged and skipped. + level. Raises if the API omits any configured vault. """ - addr_to_meta: dict[str, tuple[Chain, str, int]] = {} - addresses: list[str] = [] - chain_ids: list[int] = [] - for chain, vaults in VAULTS_V2_BY_CHAIN.items(): - chain_ids.append(chain.chain_id) - for entry in vaults: - name, address, risk = str(entry[0]), Web3.to_checksum_address(str(entry[1])), int(str(entry[2])) - addr_to_meta[address.lower()] = (chain, name, risk) - addresses.append(address) + addr_to_meta, addresses, chain_ids = get_vault_query_config(VAULTS_V2_BY_CHAIN) if not addresses: return {} - try: - response = request_with_retry( - "post", - API_URL, - json={ - "query": _STATE_QUERY, - "variables": {"addresses": addresses, "chainIds": sorted(set(chain_ids))}, - }, - ) - except requests.RequestException as e: - logger.warning("Failed to fetch v2 vault state: %s", e) - return {chain: [] for chain in SUPPORTED_CHAINS} - - data = response.json() - if "errors" in data: - logger.warning("GraphQL errors fetching v2 state: %s", data["errors"]) - return {chain: [] for chain in SUPPORTED_CHAINS} - - items = data.get("data", {}).get("vaultV2s", {}).get("items") or [] + data = execute_graphql( + _STATE_QUERY, + {"addresses": addresses, "chainIds": chain_ids}, + "Vault V2 state", + error_type=MorphoV2MonitoringError, + ) + items = data.get("vaultV2s", {}).get("items") or [] by_addr: dict[str, dict[str, Any]] = {item["address"].lower(): item for item in items} + require_configured_keys( + addr_to_meta, + by_addr, + "Vault V2 addresses", + error_type=MorphoV2MonitoringError, + ) - result: Dict[Chain, List[V2Vault]] = {chain: [] for chain in SUPPORTED_CHAINS} - for addr_lc, (chain, name, risk_level) in addr_to_meta.items(): - item = by_addr.get(addr_lc) - if item is None: - logger.warning("V2 vault %s on %s missing from GraphQL response", addr_lc, chain.name) - continue + result: Dict[Chain, List[V2Vault]] = {chain: [] for chain in VAULTS_V2_BY_CHAIN} + for addr_lc, (chain, config) in addr_to_meta.items(): + item = by_addr[addr_lc] result.setdefault(chain, []).append( V2Vault( - name=name, + name=config.name, address=Web3.to_checksum_address(item["address"]), 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=risk_level, + 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 [], ) ) @@ -204,8 +200,7 @@ def list_adapters(client: Web3Client, vault_address: str) -> List[str]: try: length = vault.functions.adaptersLength().call() except Exception as e: - logger.warning("adaptersLength() reverted for %s: %s", vault_address, e) - return [] + 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: @@ -254,8 +249,7 @@ def classify_adapter(client: Web3Client, adapter_address: str) -> AdapterInfo: wrapped_v1_vault=Web3.to_checksum_address(wrapped), ) except Exception as e: - logger.warning("Adapter %s could not be classified: %s", adapter_address, e) - return AdapterInfo(address=adapter_address, kind=ADAPTER_KIND_UNKNOWN) + raise MorphoV2MonitoringError(f"Adapter {adapter_address} could not be classified: {e}") from e # ---------------------------------------------------------------------------- @@ -263,21 +257,6 @@ def classify_adapter(client: Web3Client, adapter_address: str) -> AdapterInfo: # ---------------------------------------------------------------------------- -def _market_risk_level(market_id: str, chain: Chain) -> int: - """Look up the Morpho Blue market's risk tier from v1 tables (1-5).""" - mid = market_id.lower() - for tier, table in ( - (1, MARKETS_RISK_1), - (2, MARKETS_RISK_2), - (3, MARKETS_RISK_3), - (4, MARKETS_RISK_4), - (5, MARKETS_RISK_5), - ): - if mid in (m.lower() for m in table.get(chain, [])): - return tier - return 5 - - def score_market_allocations( vault: V2Vault, market_adapters: List[AdapterInfo], @@ -293,86 +272,120 @@ def score_market_allocations( if vault_total_assets_usd <= 0 or not market_adapters: return - # Sum allocations per market_id across adapters, in case the same market - # is wired through more than one 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): - mid = market_id.lower() - underlying_per_market[mid] = underlying_per_market.get(mid, 0) + int(expected_assets) - + underlying_per_market = _aggregate_expected_assets(market_adapters) if not underlying_per_market: 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(): - market = metrics.get(market_id) - if not market: - logger.info("No GraphQL data for market %s; skipping", market_id) + assessment = _assess_market(vault, market_id, expected_assets, metrics, vault_total_assets_usd) + if assessment is None: continue + total_risk_score += assessment.risk_score + if assessment.allocation_violation is not None: + allocation_violations.append(assessment.allocation_violation) + if assessment.bad_debt_alert is not None: + bad_debt_alerts.append(assessment.bad_debt_alert) - 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) - continue - allocation_ratio = min(allocation_usd / vault_total_assets_usd, 1.0) - risk_level = _market_risk_level(market_id, vault.chain) - threshold = get_market_allocation_threshold(risk_level, vault.risk_level) - total_risk_score += risk_level * allocation_ratio - - market_label = _market_label(market, market_id, vault.chain) - if allocation_ratio > threshold: - allocation_violations.append( - f"- {market_label} (risk {risk_level}): {allocation_ratio:.1%} (max: {threshold:.1%})" - ) + _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 - # Bad debt — same threshold as v1. - bad_debt_usd = market.bad_debt.usd - borrow_usd = market.state.borrow_assets_usd - if borrow_usd > 0 and bad_debt_usd / borrow_usd > BAD_DEBT_RATIO: - bad_debt_alerts.append( - f"- {market_label}: ${bad_debt_usd:,.2f} ({bad_debt_usd / borrow_usd:.2%} of borrowed)" - ) +def _assess_market( + vault: V2Vault, + market_id: str, + expected_assets: int, + 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) + if market is None: + 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) + risk_level = get_market_risk_level(market_id, vault.chain) + assessment = assess_exposure( + allocation_ratio, + risk_level, + vault.risk_level, + bad_debt_usd=market.bad_debt.usd, + borrow_assets_usd=market.state.borrow_assets_usd, + ) + market_label = _market_label(market, market_id, vault.chain) + allocation_violation = None + if assessment.allocation_exceeded: + allocation_violation = ( + f"- {market_label} (risk {risk_level}): {assessment.allocation_ratio:.1%} " + f"(max: {assessment.allocation_threshold:.1%})" + ) + + bad_debt_usd = market.bad_debt.usd + bad_debt_alert = None + if assessment.bad_debt_exceeded: + bad_debt_alert = f"- {market_label}: ${bad_debt_usd:,.2f} ({assessment.bad_debt_ratio:.2%} of borrowed)" + + return MarketAssessment( + risk_score=assessment.risk_score, + allocation_violation=allocation_violation, + bad_debt_alert=bad_debt_alert, + ) + + +def _send_market_assessment_alerts( + vault: V2Vault, + allocation_violations: List[str], + bad_debt_alerts: List[str], +) -> None: + """Send consolidated allocation and bad-debt alerts for one vault.""" vault_url = get_vault_url(vault.address, vault.chain) if allocation_violations: - send_alert( - Alert( - AlertSeverity.HIGH, - f"🔺 V2 high allocation in [{vault.name}]({vault_url}) (risk {vault.risk_level}) " - f"on {vault.chain.name}\n" + "\n".join(allocation_violations), - PROTOCOL, - ) + message = ( + f"🔺 V2 high allocation in [{vault.name}]({vault_url}) (risk {vault.risk_level}) " + f"on {vault.chain.name}\n" + "\n".join(allocation_violations) ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) if bad_debt_alerts: - send_alert( - Alert( - AlertSeverity.HIGH, - f"🚨 V2 bad debt in [{vault.name}]({vault_url}) on {vault.chain.name}\n" + "\n".join(bad_debt_alerts), - PROTOCOL, - ) - ) + message = f"🚨 V2 bad debt in [{vault.name}]({vault_url}) on {vault.chain.name}\n" + "\n".join(bad_debt_alerts) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) - 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) + +def _alert_vault_risk(vault: V2Vault, vault_total_assets_usd: float, 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: - send_alert( - Alert( - AlertSeverity.HIGH, - f"⚠️ V2 high risk in [{vault.name}]({vault_url}) (risk {vault.risk_level}) " - f"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}", - PROTOCOL, - ) - ) + if total_risk_score <= max_risk: + return + + vault_url = get_vault_url(vault.address, vault.chain) + 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}" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) def _allocation_to_usd(market: MarketMetrics, expected_assets: int) -> Optional[float]: @@ -417,7 +430,7 @@ def analyze_vault_adapter(vault: V2Vault, adapter: AdapterInfo) -> None: allocation_ratio = _adapter_allocation_ratio(vault, adapter.address) if allocation_ratio is not None and allocation_ratio < MIN_VAULT_ADAPTER_ALLOCATION_RATIO: return - monitored = {str(entry[1]).lower() for entry in VAULTS_BY_CHAIN.get(vault.chain, [])} + 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 @@ -433,7 +446,7 @@ def analyze_vault_adapter(vault: V2Vault, adapter: AdapterInfo) -> None: 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/markets.py:VAULTS_BY_CHAIN.", + f"morpho/config.py:VAULTS_V1_BY_CHAIN.", PROTOCOL, ) ) @@ -452,11 +465,32 @@ def analyze_v2_vault(client: Web3Client, vault: V2Vault) -> None: elif adapter.kind == ADAPTER_KIND_VAULT: analyze_vault_adapter(vault, adapter) else: - logger.warning("Skipping unknown adapter kind for %s", adapter.address) + raise MorphoV2MonitoringError(f"Unsupported adapter kind {adapter.kind} for {adapter.address}") if market_adapters: score_market_allocations(vault, market_adapters, vault.total_assets_usd) + if not is_collateral_vault(vault.address, vault.chain, version=2): + check_low_liquidity(vault) + + +def check_low_liquidity(vault: V2Vault) -> None: + """Alert when a non-collateral Vault V2 has less than 1% withdrawable liquidity.""" + if not is_low_liquidity(vault.total_assets_usd, vault.liquidity_usd): + return + + vault_url = get_vault_url(vault.address, vault.chain) + message = format_low_liquidity_message( + vault.name, + vault_url, + vault.chain, + vault.total_assets_usd, + vault.liquidity_usd, + LIQUIDITY_THRESHOLD, + version_label="V2", + ) + send_alert(Alert(AlertSeverity.LOW, message, PROTOCOL)) + # ---------------------------------------------------------------------------- # Entry point @@ -471,6 +505,7 @@ def main() -> None: logger.info("No matching V2 vaults found yet.") return + failures: List[str] = [] for chain, vaults in vaults_by_chain.items(): if not vaults: continue @@ -478,14 +513,13 @@ def main() -> None: for vault in vaults: try: analyze_v2_vault(client, vault) - except Exception: + 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}") + if failures: + raise MorphoV2MonitoringError("Failed Morpho Vault V2 analyses: " + "; ".join(failures)) -# TODO: phase 2 — implement liquidity monitoring once we have real V2 vaults to -# observe. Aggregating per-adapter `realAssets()` against a chosen liquid floor -# is non-trivial because borrowed Morpho Blue markets require per-market -# headroom rather than vault-level idle assets. if __name__ == "__main__": from utils.runner import run_with_alert diff --git a/protocols/morpho/risk.py b/protocols/morpho/risk.py new file mode 100644 index 0000000..3c0df75 --- /dev/null +++ b/protocols/morpho/risk.py @@ -0,0 +1,332 @@ +"""Shared Morpho market-risk and liquidity policy.""" + +from dataclasses import dataclass + +from utils.chains import Chain + +MARKETS_RISK_1 = { + Chain.MAINNET: [ + "0x3a85e619751152991742810df6ec69ce473daef99e28a64ab2340d7b7ccfee49", # WBTC/USDC -> lltv 86%, oracle: chainlink WBTC/BTC, chainlink BTC/USD and chainlink USDC/USD + "0xb323495f7e4148be5643a4ea4a8221eef163e4bccfdedc2a6f4696baacbc86cc", # wstETH/USDC -> lltv 86%, oracle: compound oracle wstETH/ETH, chainlink ETH/USD and chainlink USDC/USD + "0x7e585a933ffe8443c371b4f8cfeb4430f5f6a14c2f32a898c26662c67a1cb8b8", # wstETH/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2 — Compound WstETHPriceFeed (Chainlink STETH/ETH feed + Lido wstETH wrapper) + Chainlink ETH/USD; no quote feed (USDC = $1). + "0x94b823e6bd8ea533b4e33fbc307faea0b307301bc48763acc4d4aa4def7636cd", # WETH/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, Chainlink ETH/USD; no quote feed (USDC = $1). + "0x64d65c9a2d91c36d56fbc42d69e979335320169b3df63bf92789e2c8883fcc64", # cbBTC/USDC -> lltv 86%, oracle: chainlink BTC/USD and chainlink USDC/USD + "0xb8fc70e82bc5bb53e773626fcc6a23f7eefa036918d7ef216ecfb1950a94a85e", # wstETH/WETH -> lltv 96.5%, oracle: lido exchange rate + "0xc54d7acf14de29e0e5527cabd7a576506870346a78a11a6762e2cca66322ec41", # wstETH/WETH -> lltv 94.5%, oracle: compound oracle wstETH/ETH + "0xd0e50cdac92fe2172043f5e0c36532c6369d24947e40968f34a5e8819ca9ec5d", # wstETH/WETH -> lltv 94.5%, oracle: lido exchange rate + "0x138eec0e4a1937eb92ebc70043ed539661dd7ed5a89fb92a720b341650288a40", # WBTC/WETH -> lltv 91.5%, oracle: chainlink BTC/ETH + "0x2cbfb38723a8d9a2ad1607015591a78cfe3a5949561b39bde42c242b22874ec0", # cbBTC/WETH -> lltv 91.5%, oracle: chainlink BTC/USD and chainlink ETH/USD + "0xa921ef34e2fc7a27ccc50ae7e4b154e16c9799d3387076c421423ef52ac4df99", # WBTC/USDT -> lltv 86%, oracle: chainlink WBTC/BTC, chainlink BTC/USD and chainlink USDT/USD + "0x3274643db77a064abd3bc851de77556a4ad2e2f502f4f0c80845fa8f909ecf0b", # sUSDS/USDT -> lltv 96.5%, oracle: chainlink USDT/USD, chainlink DAI/USD and sUSDS vault + "0xe7e9694b754c4d4f7e21faf7223f6fa71abaeb10296a4c43a54a7977149687d2", # wstETH/USDT -> lltv 86%, oracle: compound oracle wstETH/ETH, chainlink ETH/USD and chainlink USDT/USD + "0x37e7484d642d90f14451f1910ba4b7b8e4c3ccdd0ec28f8b2bdb35479e472ba7", # weETH/WETH -> lltv 94.5%, oracle: origami weETH/ETH which calls WEETH.getRate(). Alike assets. + "0x45671fb8d5dea1c4fbca0b8548ad742f6643300eeb8dbd34ad64a658b2b05bca", # cbBTC/USDT -> lltv 86%, oracle: chainlink BTC/USD, hardcoded USDT=USD. + "0x4fe72543c5c95cd6b5f3cb516cd235ba882e2e705fe3424db6f99dfe5811d0d3", # cbBTC/USDT -> lltv 86%, oracle: MetaOracleDeviationTimelock — primary MorphoChainlinkOracleV2 Chainlink BTC/USD, backup MorphoChainlinkOracleV2 Chainlink cbBTC/USD; quote feeds unset (USDT=$1). + "0x39d6cc9211d023cc16708a2378d821d394d8cfaa3640e3a4d4638d292e10035d", # cbBTC/WBTC -> lltv 94.5%, oracle: chainlink cbBTC/USD and chainlink WBTC/USD. + "0xab04bdfbeef6de62e3020d44710d6461eccfb901b9659f866844805fca115f2f", # cbBTC/WBTC -> lltv 94.5%, oracle: MetaOracleDeviationTimelock — primary fixed 1:1 cbBTC/WBTC (price 1e36); backup MorphoChainlinkOracleV2 Chainlink cbBTC/USD and Chainlink BTC/USD. + "0x34377fc4f617c51818e92c79df31ff270c6a91bc94ad32e367fdf59b9f4ac5dd", # weETH/USDC -> lltv 77%, oracle: Chainlink weETH/ETH exchange rate and Chainlink ETH/USD; Morpho dummy quote feed (USDC = $1). + "0xf6a056627a51e511ec7f48332421432ea6971fc148d8f3c451e14ea108026549", # LBTC/WBTC -> lltv 94.5%, oracle: readstone exchange rate LBTC/BTC and chainlink WBTC/BTC + ], + Chain.BASE: [ + "0x7fc498ddcb7707d6f85f6dc81f61edb6dc8d7f1b47a83b55808904790564929a", # cbETH/EURC -> lltv 86%, oracle: Chainlink cbETH/ETH and Chainlink ETH/USD and Chainlink EURC/USD. + "0xa9b5142fa687a24c275faf731f13b52faa9873252bb4e1cb6077aa1f412edb0b", # WETH/EURC -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink EURC/USD. + "0x67ebd84b2fb39e3bc5a13d97e4c07abe1ea617e40654826e9abce252e95f049e", # cbBTC/EURC -> lltv 86%, oracle: Chainlink BTC/USD and Chainlink EURC/USD. + "0xf7e40290f8ca1d5848b3c129502599aa0f0602eb5f5235218797a34242719561", # wstETH/EURC -> lltv 86%, oracle: Chainlink wstETH-stETH Exchange Rate and Chainlink ETH/USD and Chainlink EURC/USD. + "0x8793cf302b8ffd655ab97bd1c695dbd967807e8367a65cb2f4edaf1380ba1bda", # WETH/USDC -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink USDC/USD. + "0x9103c3b4e834476c9a62ea009ba2c884ee42e94e6e314a26f04d312434191836", # cbBTC/USDC -> lltv 86%, oracle: Chainlink BTC/USD and Chainlink USDC/USD. + "0x13c42741a359ac4a8aa8287d2be109dcf28344484f91185f9a79bd5a805a55ae", # wstETH/USDC -> lltv 86%, oracle: Chainlink wstETH-stETH Exchange Rate and Chainlink ETH/USD and Chainlink USDC/USD. + "0x1c21c59df9db44bf6f645d854ee710a8ca17b479451447e9f56758aee10a2fad", # cbETH/USDC -> lltv 86%, oracle: Chainlink cbETH/ETH and Chainlink ETH/USD and Chainlink USDC/USD. + "0xdb0bc9f10a174f29a345c5f30a719933f71ccea7a2a75a632a281929bba1b535", # rETH/USDC -> lltv 86%, oracle: Chainlink rETH/ETH and Chainlink ETH/USD and Chainlink USDC/USD. + "0x3a4048c64ba1b375330d376b1ce40e4047d03b47ab4d48af484edec9fec801ba", # wstETH/WETH -> lltv 94.5%, oracle: Chainlink wstETH-stETH Exchange Rate + "0x84662b4f95b85d6b082b68d32cf71bb565b3f22f216a65509cc2ede7dccdfe8c", # cbETH/WETH -> lltv 94.5%, oracle: Chainlink cbETH-ETH Exchange Rate + "0x5dffffc7d75dc5abfa8dbe6fad9cbdadf6680cbe1428bafe661497520c84a94c", # cbBTC/WETH -> lltv 91.5%, oracle: Chainlink BTC/USD and Chainlink ETH/USD + "0xa7813c754ddd6a24e1a1a29ff3ea877803ac63d09efc2f121b1cf3f0bf3af2f6", # WETH/cbBTC -> lltv 91.5%, oracle: Chainlink ETH/USD and Chainlink BTC/USD + "0x3b3769cfca57be2eaed03fcc5299c25691b77781a1e124e7a8d520eb9a7eabb5", # USDC/WETH -> lltv 86.5%, oracle: Chainlink USDC/USD and Chainlink ETH/USD + ], + Chain.KATANA: [ + "0xcd2dc555dced7422a3144a4126286675449019366f83e9717be7c2deb3daae3e", # vbWBTC/vbUSDC -> lltv 86%, oracle: Chainlink WBTC/BTC, Chainlink BTC/USD and Chainlink USDC/USD + "0x2fb14719030835b8e0a39a1461b384ad6a9c8392550197a7c857cf9fcbd6c534", # vbETH/vbUSDC -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink USDC/USD + "0x60b54e17d55b765955a20908ed5143192a48df7fd3833f7f7fe86504bf6c4c1a", # LBTC/vbBTC -> lltv 91.5%, oracle: RedStone Price Feed for LBTC_FUNDAMENTAL -> Katana WBTC vault bridge accepts LBTC markets as collateral, no additional risk when using LBTC on Katana chain + "0xd3b3c992070b5a6271b11acde46cdad575e4187e499782e084d73e523153f1ed", # wstETH/vbUSDC -> lltv 86%, oracle: Chainlink wsteth/ETH, Chainlink ETH/USD and Chainlink USDC/USD + "0x4b7a328d4c03ea974acac4a4c5f092870afe707df88aa4c5d834f93d96894050", # vbETH/vbUSDC -> lltv 86%, oracle: Api3 ETH/USD and Api3 USDC/USD + "0x499a1b2827cff06de432a00b5e8c4509d4c2a7eafc638c0df6a09a8fa1c8d649", # vbWBTC/vbUSDC -> lltv 86%, oracle: Api3 BTC/USD and Api3 USDC/USD + "0x4bc9c84a5271f5196357c0ed18af783614851f23ac11652e78b9934e34baa5d1", # vbETH/vbUSDT -> lltv 86%, oracle: Api3 ETH/USD and Api3 USDT/USD + "0x9c95ce191559ba7652c7a2d74568590824c1166a2994fcef696b413c18efe7ee", # vbWBTC/vbUSDT -> lltv 86%, oracle: Api3 BTC/USD and Api3 USDT/USD + "0x1e74d36ffbda65b8a45d72754b349cdd5ce807c5fa814f91ba8e3cd27881c34b", # weETH/vbETH -> lltv 91.5%, oracle: Redstone weETH/ETH fundamental price + "0x22f9f76056c10ee3496dea6fefeaf2f98198ef597eda6f480c148c6d3aaa70db", # wstETH/vbETH -> lltv 91.5%, oracle: Redstone wstETH/ETH fundamental price + "0xc149387c455f7abf7a1f430ccc6639df55fcd366a2f5b055f611289ed1b8a956", # LBTC/vbUSDT -> lltv 86%, oracle: Redstone LBTC/BTC fundamental price and Redstone BTC/USD + "0xa0cd6b9d1fcc6baded4f7f8f93697dbe7f24f6e1fc22602a625c7a80b8e8e6ef", # LBTC/vbUSDC -> lltv 86%, oracle: Chainlink LBTC/USD and Chainlink USDC/USD + "0xcdaf57d98c2f75bffb8f0d3f7aa79bbacda4a479c47e316aab14af1ca6d85ffc", # yvUSDT/vbUSDC -> lltv 86%, oracle: yvUSDT vault rate. Chainlink USDT/USD and Chainlink USDC/USD + "0x6691cdcadd5d23ac68d2c1cf54dc97ab8242d2a888230de411094480252c2ed3", # yvUSDC/vbUSDT -> lltv 86%, oracle: yvUSDC vault rate. Chainlink USDC/USD and Chainlink USDT/USD + "0xd4ab732112fa9087c9c3c3566cd25bc78ee7be4f1b8bdfe20d6328debb818656", # vbWBTC/vbUSDT -> lltv 86%, oracle: Chainlink WBTC/USD + "0x9e03fc0dc3110daf28bc6bd23b32cb20b150a6da151856ead9540d491069db1c", # vbETH/vbUSDT -> lltv 86%, oracle: Chainlink ETH/USD + "0x08f67ef41398456dbc5ff72d43c8b6f7917abfd01498a9fc6c89dabe6eb78b8c", # yvvbETH/USDC -> lltv 77%, oracle: yearn vault exchange rate. Chainlink ETH/USD and Chainlink USDC/USD. + "0x3a22063bd258f3f75e3135cac4ec53435dfa5b47b3d5173bb8fd5278e6c1b305", # yvvbWBTC/USDC -> lltv 77%, oracle: yearn vault exchange rate. Chainlink WBTC/BTC, Chainlink BTC/USD and Chainlink USDC/USD. + "0xcfac40b9f06194a33d9526f73642f6849b908c2b6d8669ad9d2d4a3e7dcb017a", # yvAUSD/USDC -> lltv 86%, oracle: yearn vault exchange rate. Chainlink AUSD/USD and Chainlink USDC/USD. + "0xbeb2f6ad6de1a9eead3302ad57a0180f67d127a22e53fa29bc724147b96cb20d", # WBTC/AUSD -> lltv 86%, oracle: Chainlink WBTC/BTC, Chainlink BTC/USD and Chainlink AUSD/USD. + "0x02a77b251cb27b04b5ddab89c852bdc77ee85d359c082170389001d71571a967", # vbWETH/AUSD -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink AUSD/USD. + "0x0c909f9c866c4250cb2f15ef916b1eed5b1022b34ccd7ca947810011f5758c4f", # BTCK/AUSD -> lltv 86%, oracle: RedStone Price Feed for BTC. USD=AUSD. + "0xa6ce59291d90ae348b2fa956cc66f31df605a3304a9325e494c94e2cf5b0485a", # weETH/vbUSDT -> lltv 77%, oracle: RedStone weETH/ETH fundamental price, Chainlink ETH/USD and Chainlink USDT/USD. + "0x76e311d4b0e2e6ae88ad9bab18063452a6d39837d7104c430ff62457b91cb2cb", # weETH/vbUSDC -> lltv 77%, oracle: RedStone weETH/ETH fundamental price, Chainlink ETH/USD and Chainlink USDC/USD. + "0xbb4fb94ca819744df6a8f3932fffad47d31e8d76d3c48216878295c4cf588caf", # weETH/vbUSDT -> lltv 86%, oracle: RedStone weETH/ETH fundamental price, Chainlink ETH/USD and Chainlink USDT/USD. + "0x80e60fe453223b0f84a567724f88190bef708420d24397157067d424429783e9", # avKAT/KAT -> llt 77%, oracle ERC4626 avKAT/KAT vault rate + ], +} + +MARKETS_RISK_2 = { + Chain.MAINNET: [ + "0x85c7f4374f3a403b36d54cc284983b2b02bbd8581ee0f3c36494447b87d9fcab", # sUSDe/USDC -> lltv 91.5%, oracle: sUSDe vault + "0xc581c5f70bd1afa283eed57d1418c6432cbff1d862f94eaf58fdd4e46afbb67f", # USDe / USDC -> lltv 86%, same value asset but using hardcoded oracle + "0x5f8a138ba332398a9116910f4d5e5dcd9b207024c5290ce5bc87bc2dbd8e4a86", # ETH+/WETH -> lltv 94.5%, oracle: ETH+ / USD exchange rate adapter and Chainlink: ETH/USD. ETH+ token has monitoring. + "0x85ab69d50add7daa0934b5224889af0a882f2e3b4572d82c771dd0875f4eaa9b", # pufETH/WETH -> lltv 94.5%, oracle: pufETH vault exchange rate. Alike assets. + "0xbf02d6c6852fa0b8247d5514d0c91e6c1fbde9a168ac3fd2033028b5ee5ce6d0", # LBTC/USDC -> lltv 86%, oracle: Redstone LBTC / BTC Redstone redemption price feed and Chainlink BTC/USD. More info on LBTC/BTC: https://docs.redstone.finance/docs/data/lombard/#how-redstone-delivers-lbtcbtc-fundamental-price + "0xdb8938f97571aeab0deb0c34cf7e6278cff969538f49eebe6f4fc75a9a111293", # ETH+/USDC -> lltv 86%, oracle: ETH+ / USD exchange rate adapter and Chainlink: USDC/USD. ETH+ token has monitoring. + "0xe4cfbee9af4ad713b41bf79f009ca02b17c001a0c0e7bd2e6a89b1111b3d3f08", # tBTC/USDC -> lltv 77%, oracle: tBTC/USD UMA oracle that captures OEV and USDC/USD UMA oracle. + "0x550edc2e9fe71158ccfa7c478a31f4e60ef508d94ada3931dc2aee4f666f8f81", # yvUSDC-1/USDC -> lltv 91.5%, oracle: yvUSDC-1 vault rate. + "0x973e9dd45799efe8775417bcc420a3ab84a583587b2108985746e2fe201d0c83", # YFI/USDC -> lltv 77%, oracle: Chainlink YFI/USD and Chainlink USDC/USD. + "0xb8fef900b383db2dbbf4458c7f46acf5b140f26d603a6d1829963f241b82510e", # OETH/USDC -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink USDC/USD. OETH = ETH + "0xeb17955ea422baeddbfb0b8d8c9086c5be7a9cfdefb292119a102e981a30062e", # stcUSD/USDC -> lltv 91.5%, oracle: Ojo Yield Risk Engine stcUSD/cUSD Exchange Rate, RedStone Price Feed for cUSD_FUNDAMENTAL and Chainlink USDC/USD. + "0x2fb3713487c7812e7309935b034f40228841666f6b048faf31fd2110ae674f20", # PT-stcUSD-23JUL2026/USDC -> lltv 91.5%, oracle: OjoPTFeed oracle for stcUSD. RedStone Price Feed for cUSD_FUNDAMENTAL and Redstone USDC/USD v2. + "0x702b7ec7628de2622e51e1bb34a7e6ad9e95f3a25a2ed361e4ce621f23f5e642", # PT-cUSD-23JUL2026/USDC -> lltv 91.5%, oracle: OjoPTFeed oracle for cUSD. RedStone Price Feed for cUSD_FUNDAMENTAL and Redstone USDC/USD v2. + "0x729badf297ee9f2f6b3f717b96fd355fc6ec00422284ce1968e76647b258cf44", # syrupUSDC/USDC -> lltv 91.5%, oracle: syrupUSDC MaplePool vault rate. Oracle is using convertToAssets() to get the price but maple pool returns different amount, it should use convertToExitAssets() instead. + "0x61765602144e91e5ac9f9e98b8584eae308f9951596fd7f5e0f59f21cd2bf664", # weETH/USDC -> lltv 91.5%, oracle: redstone weETH/usdc exchange rate + "0xb7843fe78e7e7fd3106a1b939645367967d1f986c2e45edb8932ad1896450877", # XAUT/USDT -> lltv 77%, oracle: Chainlink XAUT/USD and Chainlink USDT/USD. + "0xc3b37a18d5b15f8e5b78bcdc014ffb3f22933bde4e5f6a36dedf36db87e68585", # WETH/RLUSD -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink RLUSD/USD. + "0x631e64ae8498821a5605bd3c14e253ffbf207f87411e2aeffc91a32a126cc13d", # WETH/PYUSD -> lltv 86%, oracle: Chainlink ETH/USD and Chainlink PYUSD/USD. + "0xc0ae375fd761ff19b3f04de5534c0f1ec110f80e1c2ede27c42c1c43c3040394", # syrupUSDC/RLUSD -> lltv 91.5%, oracle: syrupUSDC MaplePool ERC4626 to USDC, chainlink USDC/USD and Chainlink RLUSD/USD. + "0xffd010618ed3cb39bb2c5de0e3e58d3d2ec9f52187a180f29723c31756a939bc", # cbBTC/RLUSD -> lltv 86%, oracle: chainlink cbBTC/USD and Chainlink RLUSD/USD. + "0xa128dddc761075df9a9a60689f3a41a989b245aad506352c509c0c3a76a9ec6b", # WBTC/RLUSD -> lltv 86%, oracle: Chainlink WBTC/BTC, Chainlink BTC/USD and Chainlink RLUSD/USD. + "0xea4bfb18df0ee6bffb7b3f0270899a8adb92ab6b684709634c8276128813cfd4", # weETH/RLUSD -> lltv 86%, oracle: chainlink weETH/ETH and chainlink ETH/USD and Chainlink RLUSD/USD. + "0x88abdf8693e663144c3544b9442e9b04520016d6ebc57aa76424c00ab1683c9d", # wstETH/RLUSD -> lltv 86%, oracle: Compound wstETH/ETH price feed, chainlink ETH/USD and Chainlink RLUSD/USD. + "0x48a0da254e4df7b1046baa5ef11beb7916203886ce153a07a6d28c5d63cf8fad", # sUSDe/RLUSD -> lltv 91.5%, oracle: sUSDe ERC4626 vault, chainlink USDe/USD and Chainlink RLUSD/USD. + "0xf5c5df23559b0fb56560a7578ea17d81e245153ba64b8132df026c9358864d27", # wstETH/PYUSD -> lltv 86%, oracle: Compound wstETH/ETH feed, chainlink ETH/USD and Chainlink PYUSD/USD. + "0xa5beccdffd156dfe8c0871f143648c512f0a34f37c8a4ae2ff31ebfe944641d1", # sUSDS/PYUSD -> lltv 94.5%, oracle: sUSDS ERC4626 vault, chainlink USDS/USD and Chainlink PYUSD/USD. + "0xc9629945524f3fde56c7e8854a6c3d48e76b9d97236abbe73c750fcc7aeb8501", # syrupUSDC/PYUSD -> lltv 91.5%, oracle: syrupUSDC MaplePool ERC4626 to USDC, chainlink USDC/USD and Chainlink PYUSD/USD. + "0x6a7e36eb088bd501d73f7ab4c5b8671358559341a78ce521c9e499dc0bc642b9", # LBTC/PYUSD -> lltv 86%, oracle: Redstone LBTC_FUNDAMENTAL, chainlink BTC/USD and Chainlink PYUSD/USD. + "0x85d59152eeeab7ca024804895b358868d8dd1e134171be400d7792d5604a212c", # weETH/PYUSD -> lltv 86%, oracle: chainlink weETH/ETH and chainlink ETH/USD and Chainlink PYUSD/USD. + "0x90ef0c5a0dc7c4de4ad4585002d44e9d411d212d2f6258e94948beecf8b4c0d5", # sUSDe/PYUSD -> lltv 91.5%, oracle: sUSDe ERC4626 vault, chainlink USDe/USD and Chainlink PYUSD/USD. + "0xcb12dcbc7c6c4f20ca1537a3cc1a41ec27501f85a3e322a710d9a16a88a28c0e", # PT-sUSDE-7MAY2026/PYUSD -> lltv 91.5%, oracle: Pendle oracle PT to USDe, chainlink USDe/USD and Chainlink PYUSD/USD. + "0xd8a8e6667f58aa9229e8979bd619742b1660ee856c200a93e407dbccb7222323", # cbBTC/PYUSD -> lltv 86%, oracle: chainlink cbBTC/USD and Chainlink PYUSD/USD. + "0x6d2fba32b8649d92432d036c16aa80779034b7469b63abc259b17678857f31c2", # wstETH/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2 — Api3 wstETH/USD + Api3 USDC/USD. + "0xba3ba077d9c838696b76e29a394ae9f0d1517a372e30fd9a0fc19c516fb4c5a7", # cbBTC/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, Api3 cbBTC/USD + Api3 USDC/USD. + "0x15bb2a6af0c909eed19fb1f2ceeead34ecbdcba626de752c6b09389ee14eec32", # kBTC/RLUSD -> lltv 86%, oracle: Chainlink BTC/USD and Chainlink RLUSD/USD. + "0xe51f9aaad25d0e755429cf77076b3c2d37cb1228ed81f8a5482f2102c220eef5", # kBTC/PYUSD -> lltv 86%, oracle: Chainlink BTC/USD and Chainlink PYUSD/USD. + "0xe3df58f9d3011b7481ff36b939fa5f8da642f34ea5792d25d3958dbf1efa26d7", # USD3/USDC -> lltv 91.5%, oracle: MorphoChainlinkOracleV2, USD3 ERC4626 vault rate (underlying USDC). No price feeds; USDC = $1. + "0xf8c5aa31ea6b2a068a9eddb46dd110cae57bf0f12be9583a3f9a818effecba89", # PT-USD3-17DEC2026/USDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, PendleSparkLinearDiscountOracle PT feed for PT-USD3. No quote feed (USDC = $1). Discount 30% per year. + ], + Chain.BASE: [ + "0x6aa81f51dfc955df598e18006deae56ce907ac02b0b5358705f1a28fcea23cc0", # wstETH/WETH -> lltv 96.5%, oracle: Chainlink wstETH-stETH Exchange Rate + "0x6600aae6c56d242fa6ba68bd527aff1a146e77813074413186828fd3f1cdca91", # cbETH/WETH -> lltv 96.5%, oracle: cbETH-ETH logocbETH-ETH Exchange Rate + "0x78d11c03944e0dc298398f0545dc8195ad201a18b0388cb8058b1bcb89440971", # weETH/WETH -> lltv 91.5%, oracle: Chainlink weETH / eETH Exchange Rate + "0xfd0895ba253889c243bf59bc4b96fd1e06d68631241383947b04d1c293a0cfea", # weETH/WETH -> lltv 94.5%, oracle: Chainlink weETH / eETH Exchange Rate + "0xdaa04f6819210b11fe4e3b65300c725c32e55755e3598671559b9ae3bac453d7", # AERO/USDC -> lltv 62.5%, oracle: Chainlink AERO/USD and Chainlink USDC/USD + "0x5189c48e1d333d250642a96b90dc926c53f897d8b8f9e8fea71a4b14e9053fde", # steakSUSDS/USDC -> lltv: 96.5%, oracle: Maker's SSR oracle for sUSDS / USDS and dummy oracle for USDC returns 1. USDS = USDC + "0xdba352d93a64b17c71104cbddc6aef85cd432322a1446b5b65163cbbc615cd0c", # cbETH/USDC -> lltv 86.5%, oracle: Chainlink cbETH/ETH and Chainlink ETH/USD and Chainlink USDC/USD -> but low liquidity + "0x7f90d72667171d72d10d62b5828d6a5ef7254b1e33718fe0c1f7dcf56dd1edc7", # bsdETH/WETH -> lltv 91.5%, oracle: bsdETH total supply. bsdETH token has internal monitoring. + "0x144bf18d6bf4c59602548a825034f73bf1d20177fc5f975fc69d5a5eba929b45", # wsuperOETHb/WETH -> lltv 91.5%, oracle: Vault exchange rate for wsuperOETHb/superOETHb, superOETHb=ETH. wsuperOETHb token has internal monitoring. + "0x67a66cbacb2fe48ec4326932d4528215ad11656a86135f2795f5b90e501eb538", # superOETHb/USDC -> lltv 77%, oracle: Chainlink ETH/USD and Chainlink USDC/USD, 1ETH=1superOETHb + "0xd4a903dc6d949519060c7707f9604fdc9772c046e05c2e3a8fce0bd7196e4109", # cbXRP/USDC -> lltv 62.5%, oracle: Chainlink XRP/USD + "0x9125d0fa03c3137166df68bcc72283477830de2a4a5536512374c573ad4583c3", # cbLTC/USDC -> lltv 62.5%, oracle: Chainlink LTC/USD + "0x30767836635facec1282e6ef4a5981406ed4e72727b3a63a3a72c74e8279a8d7", # LBTC/cbBTC -> lltv 94.5%, oracle: RedStone Price Feed for LBTC_FUNDAMENTAL: https://app.redstone.finance/app/feeds/base/lbtc_fundamental/ + "0x0b2df036bb06b49d893a8f5578cb5a31619f46d7a79cbf11783838204cfdf9e3", # YFI/USDC -> lltv 77%, oracle: Chainlink YFI/USD and Chainlink USDC/USD. + ], + Chain.KATANA: [ + "0xfe6cb1b88d8830a884f2459962f4b96ae6e38416af086b8ae49f5d0f7f9fc0cd", # POL/vbUSDC -> lltv 77%, oracle: Chainlink POL/USD and Chainlink USDC/USD + "0xdf0f160d591f02931e44010763f892a51a480257a5ff21c41ebff874b0c7d258", # BTCK/vbUSDT -> lltv 77%, oracle: Redstone BTC/USD + "0x0e9d558490ed0cd523681a8c51d171fd5568b04311d0906fec47d668fb55f5d9", # BTCK/vbUSDC -> lltv 77%, oracle: Redstone BTC/USD + "0x913c787d438ca1dab5f5485c2d2d6e2aa2dfee47a5f02edd11331ad25b219dcf", # KAT/vbUSDC -> lltv 62.5%, oracle: Chainlink KAT/USD and Chainlink USDC/USD. + "0x24e50037bacb39950700c00851d5260e61975fb79f252ae2adbf7fdbd8db7290", # KAT/vbUSDT -> lltv 62.5%, oracle: Chainlink KAT/USD and Chainlink USDT/USD. + "0x95f193f8f999718f3ce043249f12bfaea07458aae5343fc8d6355792cc17fa6c", # avKAT/vbUSDT -> lltv 62.5%, oracle: Custom oracle ERC4626 avKAT*exit-fee/KAT, Chainlink KAT/USD and Chainlink USDT/USD. + "0xbd48214a2f12e951da20ad0b8fd83b611c693b5bbaa280b68ba4075678f2a138", # avKAT/vbUSDC -> lltv 62.5%, oracle: Custom oracle ERC4626 avKAT*exit-fee/KAT, Chainlink KAT/USD and Chainlink USDC/USD. + "0x071ed2047610c7b33e1540e49fcc0a6852cb783cca0dd7dc428f32fd791a020f", # wstETH/AUSD -> lltv 86%, oracle: RedStone Price Feed for wstETH. USD=AUSD. + "0xa7cd449cc319d65be3d0926d6b6f599a8c3434bd95ba3e91bbf1ee5e80e72b56", # LBTC/vbUSDC -> lltv 86%, oracle: RedStone Price Feed for LBTC_FUNDAMENTAL and RedStone BTC/USD. USD=vbUSDC. + "0x2c4f26c76b4de51d3c9260c15a796cd2a35efab17786d0aa78ca2e638b0f8ba8", # yvvbUSDC/vbETH -> lltv 77%, oracle: yearn vault exchange rate. Chainlink ETH/USD and Chainlink USDC/USD. + "0x61fcb4d6d1534eedeb0e0bea361745f727d73f14569d231c4a2b39232b6b7312", # yvvbUSDT/vbWBTC -> lltv 77%, oracle: yearn vault exchange rate. Chainlink WBTC/USD and Chainlink USDT/USD. + "0x09c2ecea0580698a91be0cff2bad3648b00744453c14a9bfb6be5ca7b9950908", # PT-yvvbUSDC/vbUSDT -> lltv 86%, oracle: Pendle PT exchange rate(PT to asset) yvvbUSDC with yearn vault rate. Chainlink USDC/USD and Chainlink USDT/USD. + ], +} + +MARKETS_RISK_3 = { + Chain.MAINNET: [ + "0x0cd36e6ecd9d846cffd921d011d2507bc4c2c421929cec65205b3cd72925367c", # Curve TricryptoLLAMA LP / crvUSD -> collaterals: crvUSD, wstETH, tBTC. + "0x198132864e7974fb451dfebeb098b3b7e7e65566667fb1cf1116db4fb2ad23f9", # PT-LBTC-27MAR2025 / WBTC -> lltv 86%, oracle: Pendle PT exchange rate, readstone exchange rate LBTC/BTC and chainlink WBTC/BTC. + "0x8a0384fe5b1a68ff217845752287f432029b20754fbce577b6a5f8a80030a825", # PT-LBTC-26JUN2025 / WBTC -> lltv 91.5%, oracle: Pendle PT exchange rate, readstone exchange rate LBTC/BTC + "0xba761af4134efb0855adfba638945f454f0a704af11fc93439e20c7c5ebab942", # rsETH/WETH -> lltv 94.5%, oracle: origami rsETH/ETH which calls KELP_LRT_ORACLE.rsETHPrice(). Oracle address: https://etherscan.io/address/0x349A73444b1a310BAe67ef67973022020d70020d + "0xa0534c78620867b7c8706e3b6df9e69a2bc67c783281b7a77e034ed75cee012e", # ezETH/WETH -> lltv 94.5%, oracle: origami ezETH/ETH which calls renzoOracle()).calculateRedeemAmount(). It is hypothetical price, not the actual price. + "0x8e7cc042d739a365c43d0a52d5f24160fa7ae9b7e7c9a479bd02a56041d4cf77", # USR/USDC -> lltv 91.5%, oracle: USR/USD price aggregator which is checking reserves and defining max price as 1 + "0x97bb820669a19ba5fa6de964a466292edd67957849f9631eb8b830c382f58b7f", # MKR/USDC -> lltv 77%, oracle: Chainlink MKR/USD and Chainlink USDC/USD. + "0x718af3af39b183758849486340b69466e3e89b84b7884188323416621ee91cb7", # UNI/USDC -> lltv 62%, oracle: Chainlink UNI/USD and Chainlink USDC/USD. + "0x9c765f69d8a8e40d2174824bc5107d05d7f0d0f81181048c9403262aeb1ab457", # LINK/USDC -> lltv 77%, oracle: Chainlink LINK/USD and Chainlink USDC/USD. + "0xb7ad412532006bf876534ccae59900ddd9d1d1e394959065cb39b12b22f94ff5", # agETH/WETH -> lltv 91.5%, oracle: rsETH/ETH exchange rateainlink ETH/USD. Alike assets. + "0x1eda1b67414336cab3914316cb58339ddaef9e43f939af1fed162a989c98bc20", # USD0++/USDC -> lltv 96.5%, oracle: Naked USD0++ price feed adapter + "0xf9e56386e74f06af6099340525788eec624fd9c0fc0ad9a647702d3f75e3b6a9", # clUSD/USDC -> lltv 96.5%, oracle: Chainlink clUSD/USD + "0xd9e34b1eed46d123ac1b69b224de1881dbc88798bc7b70f504920f62f58f28cc", # wstUSR/USDC -> lltv 91.5%, oracle: wstUSR vault rate. USR/USD price aggregator which is checking reserves and defining max price as 1 + "0x53ed197357128ed96070e20ba9f5af4250cda6c67dcac5246876beb483f51303", # sDOLA/USDC -> lltv 91.5%, oracle: sDOLA vault rate. DOLA = USDC hardcoded oracle. + "0x7a7018e22a8bb2d08112eae9391e09f065a8ae7ae502c1c23dc96c21411a6efd", # EIGEN/USDC -> lltv 77%, oracle: Redstone EIGEN/USD. USD = USDC. + "0xce68c7aa336675e42bbc8eaa8b5ecc7ebd816bf8625b5316330c6ac2dabc4cf2", # SolvBTC/BTC -> lltv 94.5%, oracle: upgradeable MetaOracleDeviationTimelock with prime oracle morpho oracle with 1:1 hardcoded rate. same assets + "0xbbf7ce1b40d32d3e3048f5cf27eeaa6de8cb27b80194690aab191a63381d8c99", # siUSD/USDC -> lltv 91.5%, oracle: infinity accouting contract provides the price iUSD, vault rate siUSD to iUSD. usdc = 1 using dummy oracle. + "0xaac3ffcdf8a75919657e789fa72ab742a7bbfdf5bb0b87e4bbeb3c29bbbbb05c", # PT-siUSD-26MAR2026/USDC -> lltv 91.5%, oracle: ChainlinkOracleV2 — Pendle Chainlink-compatible PT feed, InfiniFi RT oracle (baseFeedTwo), dummy USDC feed (quote). + "0xdf034d0351a4c0af947e1a37ecd5ccbce60d72eac90de6fcad48c74e2869d14c", # PT-iUSD-25JUN2026/USDC -> lltv 91.5%, oracle: same stack as PT-siUSD row but Ojo PT Feed (Pendle-compatible) for PT leg; InfiniFi RT + dummy USDC. + "0xc6ae8e71e11ef511acee3f6cc6ad2af67b862877d459e3789905f537c85db5e3", # PT-sUSDE-25SEP2025/DAI -> lltv 91.5%, oracle: PendleSparkLinearDiscountOracle with linear discount oracle for sUSDE. No price oracle for DAI, USDe = DAI. + "0x27b9a0a5bfee98a31eb51e3850250d103a9f8e41673c782defc66aa943af0e65", # PT-srUSDe-2APR2026/USDC -> lltv 91.5%, oracle: Pendle PT exchange rate(PT to asset) srUSDe. USDC = 1 using dummy oracle. + ], + Chain.BASE: [ + "0x4944a1169bc07b441473b830308ffe5bb535c10a9f824e33988b60738120c48e", # LBTC/cbBTC -> lltv 91.5%, oracle: Custom moonwell oracle. Base feed is fetched from upgradeable oracle which uses 2 oracles. Primary oracle is redstone oracle, if the price changes more than 2% than it uses fallback oracle chainlink oracle. Chainlink didn't have an exchange rate feed. Redstone was the only provider for the LBTC reserves. + "0x214c2bf3c899c913efda9c4a49adff23f77bbc2dc525af7c05be7ec93f32d561", # wrsETH/WETH -> lltv 94.5%, oracle: Chainlink wrsETH/ETH exchange rate + "0x6a331b22b56c9c0ee32a1a7d6f852d2c682ea8b27a1b0f99a9c484a37a951eb7", # weETH/USDC -> lltv 77%, oracle: Chainlink weETH / eETH Exchange Rate and Chainlink ETH/USD and Chainlink USDC/USD + "0x52a2a376586d0775e3e80621facc464f6e96d81c8cb70fd461527dde195a079f", # LBTC/USDC -> lltv 86%, oracle: RedStone Price Feed for LBTC/BTC and Chainlink BTC/USD + "0xfdfecf85a4dd90a7637ae2aaf28b35061166f0e62bfc714c565eed9f7e959783", # cbXRP/USDC -> lltv 77%, oracle: Chainlink XRP/USD, Higher LLTV. + "0xdc69cf2caae7b7d1783fb5a9576dc875888afad17ab3d1a3fc102f741441c165", # rETH/WETH -> lltv 94.5%, oracle: Chainlink rETH/ETH, high risk oracle + "0x0103cbcd14c690f68a91ec7c84607153311e9954c94ac6eac06c9462db3fabb6", # rETH/EURC -> lltv 94.5%, oracle: Chainlink rETH/ETH, high risk oracle + "0x73527ddd796e6d4f48387adaae36f6f3d49d606d7f2a15eb0c931416a58875d8", # cbDOGE/USDC -> lltv 62.5%, oracle: Chainlink DOGE/USD + ], + Chain.KATANA: [ + "0xd8a93a4cd16f843c385391e208a9a9f2fd75aedfcca05e4810e5fbfcaa6baec6", # wsrUSD/vbUSDC -> lltv 91.5%, oracle: API3 wsrUSD/rUSD Exchange Rate, rUSD = vbUSDC. + "0xf7fc5cc82200ddf8f23188ddbd6727eda2c8bc41863e91fb767bbc6e4f71890e", # siUSD/vbUSDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, Chainlink SIUSD/USD; no quote feed (vbUSDC = USD). + "0xea8f588be62079a1ad874bf7c7217166b323e0fe8ea3e59b584430ed1b859ace", # stcUSD/vbUSDC -> lltv 86%, oracle: MorphoChainlinkOracleV2, Chainlink STCAPUSD/CAPUSD exchange rate, Chainlink CAPUSD/USD and Chainlink USDC/USD. + ], + Chain.ARBITRUM: [ + "0x71c2954e00c8f72864600c9d1d1cd70fa15202c4294cd938d80add3be2eced26", # sUSDai/USDC -> lltv 91.5%, oracle: Chronicle sUSDai/USD and Chainlink USDC/USD. + "0x8147c63f3f6f5a0825c84bf2cb11443c72b609fa39cf9a362e3d4dc2c5ca76c4", # PT-USDai-19FEB2026/USDC -> lltv 91.5% oracle: MetaOracleDeviationTimelock by Steakhouse with primary oracle set to Pendle PT where USDai=USDC and backup oracle set to + "0x7717f1e04510390518811b3133ea47c298094ddd1d806ed8f8867d88c727bad7", # PT-sUSDai-19FEB2026/USDC -> lltv 86%, oracle: Pendle PT exchange rate(PT to asset) sUSDai with ERC4626 vault rate sUSDai to USDai. Chainlink oracle USDC/USD. + ], +} + +MARKETS_RISK_4 = { + Chain.MAINNET: [ + "0x3c83f77bde9541f8d3d82533b19bbc1f97eb2f1098bb991728acbfbede09cc5d", # rETH/WETH -> lltv 94.5%, oracle: gravita rETH/ETH. Can change owner and aggregator. + "0xe95187ba4e7668ab4434bbb17d1dfd7b87e878242eee3e73dac9fdb79a4d0d99", # EIGEN/USDC -> lltv 77%, oracle: Redstone EIGEN/USD. USD = USDC. + "0x444327b909aa41043cc4f20209eefb2fbb37f1c38ff9ca312374a4ecc3f0a871", # SolvBTC/USDC -> lltv 86%, oracle: chainlink BTC/USD and chainlink USDC/USD + "0x2287407f0f42ad5ad224f70e4d9da37f02770f79959df703d6cfee8afc548e0d", # STONE/WETH -> lltv 94.5%, centralization risk + "0xf78b7d3a62437f78097745a5e3117a50c56a02ec5f072cba8d988a129c6d4fb6", # beraSTONE/WETH -> lltv 91.5%, centralization risk. chainlink ETH/USD + "0xcacd4c39af872ddecd48b650557ff5bcc7d3338194c0f5b2038e0d4dec5dc022", # rswETH/WETH -> lltv 94.5%, unknown asset + "0x0eed5a89c7d397d02fd0b9b8e42811ca67e50ed5aeaa4f22e506516c716cfbbf", # pufETH/WETH -> lltv 86%, oracle: pufETH vault exchange rate. Low liquidity market. + "0x7e9c708876fa3816c46aeb08937b51aa0461c2af3865ecb306433db8a80b1d1b", # pufETH/USDC -> lltv 77%, oracle: pufETH vault exchange rate. Low liquidity market. + "0x514efda728a646dcafe4fdc9afe4ea214709e110ac1b2b78185ae00c1782cc82", # swBTC/WBTC -> lltv 94.5%, same asset, check swBTC liquidity before moving up + "0x20c488469064c8e2f892dab33e8c7a631260817f0db57f7425d4ef1d126efccb", # Re7wstETH/WETH -> lltv 91.5%, unknown asset + "0xd925961ad5df1d12f677ff14cf20bac37ea5ef3b325d64d5a9f4c0cc013a1d47", # stUSD/USDC -> lltv 96.5%, oracle: stUSD vault rate. Angle transmuter handles USDA -> USDC conversion. + "0xbf6687cb042a09451e66ebc11d7716c49fb8ccc75f484f7fab0eed6624bd5838", # mMEV/USDC -> lltv 91.5%, oracle: Midas price oracle mMEV/USD. More info at: https://docs.midas.app/defi-integration/price-oracle + "0x83b7ad16905809ea36482f4fbf6cfee9c9f316d128de9a5da1952607d5e4df5e", # csUSDL/USDC -> lltv 96.5%, oracle: wUSDL / USDL vault rate. + "0xe1b65304edd8ceaea9b629df4c3c926a37d1216e27900505c04f14b2ed279f33", # RLP/USDC -> lltv 86%, oracle: RLP oracle where the price is set manually, but must be in bounds. Owner of the proxy is multisig. + "0x8b1bc4d682b04a16309a8adf77b35de0c42063a7944016cfc37a79ccac0007b6", # slvlUSD/USDC -> lltv 91.5%, oracle: slvlUSD vault rate. lvlUSD = USDC + "0x95c28d447950ca6c8bbfd25fc05b80b1fd7a1cdd17a3610b4b3f1ffc8dc2e2ed", # mHYPER/USDC -> lltv 86%, oracle: MHyperCustomAggregatorFeed + "0xe83d72fa5b00dcd46d9e0e860d95aa540d5ec106da5833108a9f826f21f36f52", # AA_FalconXUSDC/USDC -> lltv 77%, oracle: TranchesChainlinkOracle virtual price of current tranches. + ], + Chain.BASE: [ + "0xff0f2bd52ca786a4f8149f96622885e880222d8bed12bbbf5950296be8d03f89", # USR/USDC -> lltv 91.5%, oracle: pyth USR/USD and pyth USDC/USD + ], + Chain.KATANA: [], +} + +MARKETS_RISK_5 = { + Chain.MAINNET: [ + "0xbfed072faee09b963949defcdb91094465c34c6c62d798b906274ef3563c9cac", # srUSD/USDC -> lltv 91.5%, oracle: saving rate module price. rUSD(USD) is underlying asset. rUSD = USDC hardcoded oracle. + "0x0f9563442d64ab3bd3bcb27058db0b0d4046a4c46f0acd811dacae9551d2b129", # sdeUSD/USDC -> lltv 91.5%, oracle: sdeUSD vault rate. Redstone oracle deusd/usd price, 24hour heartbeat, deviation 0.2%: https://app.redstone.finance/app/feeds/ethereum-mainnet/deusd_fundamental/ + ], + Chain.BASE: [], + Chain.KATANA: [ + "0x16ded80178992b02f7c467c373cfc9f4eee7f0356df672f6a768ec92b2ffdeff", # yUSD/vbUSDC -> lltv 86%, oracle: yUSD vault rate. yUSD = vbUSDC hardcoded oracle. + ], +} + +# Define base allocation tiers +ALLOCATION_TIERS = { + 1: 1.01, # Risk tier 1 max allocation # TODO: think about lowering this to 0.80 but some vaults use 100% allocation to one market + 2: 0.30, # Risk tier 2 max allocation + 3: 0.10, # Risk tier 3 max allocation + 4: 0.05, # Risk tier 4 max allocation + 5: 0.01, # Unknown market max allocation +} + +# Define max risk thresholds by risk level (each tier is level * 1.15, capped at 5.00) +MAX_RISK_THRESHOLDS = { + 1: 1.15, # Risk tier 1 max total risk + 2: 2.30, # Risk tier 2 max total risk + 3: 3.45, # Risk tier 3 max total risk + 4: 4.60, # Risk tier 4 max total risk + 5: 5.00, # Risk tier 5 max total risk +} + + +@dataclass(frozen=True) +class ExposureAssessment: + """Shared risk-policy result for one vault market exposure.""" + + allocation_ratio: float + market_risk_level: int + allocation_threshold: float + risk_score: float + bad_debt_ratio: float + + @property + def allocation_exceeded(self) -> bool: + """Return whether the allocation exceeds its configured tier limit.""" + return self.allocation_ratio > self.allocation_threshold + + @property + def bad_debt_exceeded(self) -> bool: + """Return whether market bad debt exceeds the alert threshold.""" + return self.bad_debt_ratio > BAD_DEBT_RATIO + + +BAD_DEBT_RATIO = 0.005 +LIQUIDITY_THRESHOLD = 0.01 +MIN_VAULT_ASSETS_USD = 10_000 + + +def get_market_allocation_threshold(market_risk_level: int, vault_risk_level: int) -> float: + """Return the maximum allocation for a market/vault risk-tier pair.""" + adjusted_risk = max(1, market_risk_level - (vault_risk_level - 1)) + return ALLOCATION_TIERS[adjusted_risk] + + +def get_market_risk_level(market_id: str, chain: Chain) -> int: + """Return the configured 1-5 risk tier for a Morpho market.""" + for risk_level, markets_by_chain in ( + (1, MARKETS_RISK_1), + (2, MARKETS_RISK_2), + (3, MARKETS_RISK_3), + (4, MARKETS_RISK_4), + (5, MARKETS_RISK_5), + ): + configured = {configured_id.lower() for configured_id in markets_by_chain.get(chain, [])} + if market_id.lower() in configured: + return risk_level + return 5 + + +def assess_exposure( + allocation_ratio: float, + market_risk_level: int, + vault_risk_level: int, + *, + bad_debt_usd: float = 0, + borrow_assets_usd: float = 0, +) -> ExposureAssessment: + """Apply common allocation, weighted-risk, and bad-debt policy.""" + allocation_ratio = min(max(allocation_ratio, 0), 1.0) + bad_debt_ratio = calculate_bad_debt_ratio(bad_debt_usd, borrow_assets_usd) + return ExposureAssessment( + allocation_ratio=allocation_ratio, + market_risk_level=market_risk_level, + allocation_threshold=get_market_allocation_threshold(market_risk_level, vault_risk_level), + risk_score=market_risk_level * allocation_ratio, + bad_debt_ratio=bad_debt_ratio, + ) + + +def calculate_bad_debt_ratio(bad_debt_usd: float, borrow_assets_usd: float) -> float: + """Return bad debt as a share of borrowed assets, or zero without borrows.""" + return bad_debt_usd / borrow_assets_usd if borrow_assets_usd > 0 else 0 + + +def is_bad_debt_excessive(bad_debt_usd: float, borrow_assets_usd: float) -> bool: + """Return whether a market exceeds the common bad-debt alert threshold.""" + return calculate_bad_debt_ratio(bad_debt_usd, borrow_assets_usd) > BAD_DEBT_RATIO + + +def is_low_liquidity(total_assets_usd: float, liquidity_usd: float) -> bool: + """Return whether a material vault has less than the configured liquid share.""" + if total_assets_usd < MIN_VAULT_ASSETS_USD: + return False + return liquidity_usd / total_assets_usd < LIQUIDITY_THRESHOLD diff --git a/pyproject.toml b/pyproject.toml index eb00e26..1b4d95e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,6 +141,12 @@ disallow_untyped_decorators = true no_implicit_optional = true strict_optional = true +[tool.pytest.ini_options] +filterwarnings = [ + # web3 imports this compatibility module; monitoring code does not use the deprecated API. + "ignore:websockets\\.legacy is deprecated:DeprecationWarning:websockets\\.legacy", +] + [[tool.mypy.overrides]] module = ["web3.*", "cytoolz.*"] ignore_missing_imports = true diff --git a/tests/test_morpho_governance.py b/tests/test_morpho_governance.py new file mode 100644 index 0000000..dde8e46 --- /dev/null +++ b/tests/test_morpho_governance.py @@ -0,0 +1,65 @@ +"""Behavior tests for Morpho Vault V1 governance monitoring.""" + +import unittest +from unittest.mock import patch + +from protocols.morpho import governance +from protocols.morpho.governance import MarketGovernanceState +from utils.chains import Chain + + +class TestMorphoV1GovernanceAlerts(unittest.TestCase): + def test_new_pending_cap_alert_uses_shared_market_metadata(self) -> None: + state = MarketGovernanceState( + vault_address="0x" + "11" * 20, + market_id="0x" + "ab" * 32, + pending_cap=2_000_000, + pending_cap_timestamp=2_000_000_000, + current_cap=1_000_000, + removable_at=0, + ) + + with ( + patch("protocols.morpho.governance.get_last_executed_morpho_from_file", return_value=0), + patch("protocols.morpho.governance.fetch_market_info", return_value=("WETH/USDC (86.00%)", 6)), + patch("protocols.morpho.governance.write_last_executed_morpho_to_file") as write, + patch("protocols.morpho.governance.send_alert") as send, + ): + governance.check_market_governance_state("Example", state, Chain.MAINNET) + + alert = send.call_args.args[0] + self.assertIn("WETH/USDC (86.00%)", alert.message) + self.assertIn("difference: 100.00%", alert.message) + write.assert_called_once_with( + state.vault_address, + state.market_id, + governance.PENDING_CAP_TYPE, + state.pending_cap_timestamp, + ) + + def test_previously_alerted_market_removal_is_not_repeated(self) -> None: + state = MarketGovernanceState( + vault_address="0x" + "11" * 20, + market_id="0x" + "ab" * 32, + pending_cap=0, + pending_cap_timestamp=0, + current_cap=0, + removable_at=2_000_000_000, + ) + + with ( + patch( + "protocols.morpho.governance.get_last_executed_morpho_from_file", + return_value=state.removable_at, + ), + patch("protocols.morpho.governance.write_last_executed_morpho_to_file") as write, + patch("protocols.morpho.governance.send_alert") as send, + ): + governance.check_market_governance_state("Example", state, Chain.MAINNET) + + send.assert_not_called() + write.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_morpho_markets.py b/tests/test_morpho_markets.py new file mode 100644 index 0000000..98d6274 --- /dev/null +++ b/tests/test_morpho_markets.py @@ -0,0 +1,280 @@ +"""Behavior tests for Morpho v1/v2 market and liquidity monitoring.""" + +import unittest +from typing import Any +from unittest.mock import MagicMock, patch + +from protocols.morpho._shared import ( + Asset, + BadDebt, + MarketMetrics, + MarketState, + MorphoMonitoringError, + MorphoV2MonitoringError, +) +from protocols.morpho.config import VAULTS_V2_BY_CHAIN, get_collateral_vaults_by_asset +from protocols.morpho.markets import ( + YV_COLLATERAL_AT_RISK_POINTS, + YV_COLLATERAL_STABLE_PRICE_SHOCK, + calculate_combined_metrics, + collect_yv_collateral_markets, + fetch_configured_vaults, + get_markets_collateral_at_risk_usd, + get_yv_collateral_liquidity_by_asset, +) +from protocols.morpho.markets_v2 import ( + AdapterInfo, + V2Vault, + check_low_liquidity, + discover_v2_vaults_by_chain, + list_adapters, + score_market_allocations, +) +from utils.chains import Chain + + +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]} + expected = { + "0x4284d4f9f4d61ea57b8f0943547c7c19c5b9b249", + "0xca44cbe1fb03691d43d2d93aa460e2fcb03878fe", + "0xa2d38c8a3d810ebcf4c2075821c5ec8f976bb692", + "0xac596ad9771a8d0d4df108ae0406e6f913aedceb", + "0x5920a6fc553af799542eda628adfcc9ea52e141c", + "0xbeeff2d5d126d4809195eea02b605423917bb6c6", + "0xbeef042bad4472c3f7eb9a73070703788b5362d7", + } + + self.assertTrue(expected <= configured) + + collateral_vaults = { + vault.address.lower() + for vaults in get_collateral_vaults_by_asset(Chain.KATANA, version=2).values() + for vault in vaults + } + self.assertEqual(collateral_vaults, expected) + + def test_discovery_fails_if_api_omits_configured_vaults(self) -> None: + response = MagicMock() + response.json.return_value = {"data": {"vaultV2s": {"items": []}}} + + with ( + patch("protocols.morpho._shared.request_with_retry", return_value=response), + self.assertRaisesRegex(MorphoV2MonitoringError, "omitted configured Vault V2"), + ): + 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" + ) + + with self.assertRaisesRegex(MorphoV2MonitoringError, "Failed to read adaptersLength"): + list_adapters(client, "0x" + "11" * 20) + + def test_market_scoring_consolidates_all_v2_risk_alerts(self) -> None: + market_id = "0x" + "ab" * 32 + vault = V2Vault( + name="Example", + address="0x" + "11" * 20, + chain=Chain.MAINNET, + asset_address="0x" + "22" * 20, + asset_symbol="USDC", + curator="", + owner="", + risk_level=1, + ) + 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), + ) + + 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) + + messages = [call.args[0].message for call in send.call_args_list] + self.assertEqual(len(messages), 3) + self.assertTrue(any("V2 high allocation" in message for message in messages)) + self.assertTrue(any("V2 bad debt" in message for message in messages)) + self.assertTrue(any("V2 high risk" in message for message in messages)) + + +class TestMorphoCollateralLiquidity(unittest.TestCase): + def test_collateral_curve_can_resolve_stable_price_shock(self) -> None: + self.assertGreaterEqual(YV_COLLATERAL_AT_RISK_POINTS, round(1 / YV_COLLATERAL_STABLE_PRICE_SHOCK)) + + def test_collateral_risk_api_failure_is_not_silently_skipped(self) -> None: + response = MagicMock() + response.json.return_value = {"errors": [{"message": "unavailable"}]} + + with ( + patch("protocols.morpho._shared.request_with_retry", return_value=response), + self.assertRaisesRegex(MorphoMonitoringError, "errors fetching collateral at risk"), + ): + get_markets_collateral_at_risk_usd({"0x" + "ab" * 32: 0.02}, Chain.KATANA) + + def test_configured_data_fetch_fails_if_v1_vaults_are_omitted(self) -> None: + response = MagicMock() + response.json.return_value = { + "data": { + "vaults": {"items": []}, + "vaultV2s": {"items": []}, + "markets": {"items": []}, + } + } + + with ( + patch("protocols.morpho._shared.request_with_retry", return_value=response), + self.assertRaisesRegex(MorphoMonitoringError, "omitted configured Vault V1"), + ): + fetch_configured_vaults() + + def test_collateral_markets_do_not_depend_on_v1_vault_allocations(self) -> None: + market_id = "0x6691cdcadd5d23ac68d2c1cf54dc97ab8242d2a888230de411094480252c2ed3" + asset_address = "0x203a662b0bd271a6ed5a60edfbd04bfce608fd36" + market = { + "marketId": market_id, + "collateralAsset": {"chain": {"id": Chain.KATANA.chain_id}}, + "state": {"borrowAssetsUsd": 100_000}, + } + liquidity_group = {"asset_address": asset_address} + + result = collect_yv_collateral_markets( + Chain.KATANA, + [market], + {asset_address: liquidity_group}, + ) + + self.assertEqual(result, {market_id: (market, liquidity_group)}) + + def test_combines_v1_and_v2_withdrawable_liquidity(self) -> None: + vaults: list[dict[str, Any]] = [ + { + "__typename": "Vault", + "name": "Yearn OG USDC", + "state": {"totalAssetsUsd": 100_000}, + "liquidity": {"usd": 30_000}, + }, + { + "__typename": "VaultV2", + "name": "Yearn OG USDC", + "totalAssetsUsd": 200_000, + "liquidityUsd": 80_000, + }, + { + "__typename": "VaultV2", + "name": "Dust", + "totalAssetsUsd": 9_999, + "liquidityUsd": 9_999, + }, + ] + + total_assets, liquidity, names = calculate_combined_metrics(vaults) + + self.assertEqual(total_assets, 300_000) + self.assertEqual(liquidity, 110_000) + self.assertEqual(names, ["Yearn OG USDC", "Yearn OG USDC (V2)"]) + + def test_shared_market_liquidity_is_counted_once_across_v1_and_v2(self) -> None: + market_id = "0x" + "ab" * 32 + market = { + "marketId": market_id, + "collateralAsset": {"symbol": "WETH"}, + "state": {"liquidityAssetsUsd": 100_000}, + } + vaults: list[dict[str, Any]] = [ + { + "__typename": "Vault", + "name": "V1", + "state": { + "totalAssetsUsd": 100_000, + "allocation": [ + { + "supplyAssetsUsd": 80_000, + "withdrawQueueIndex": 0, + "market": market, + } + ], + }, + "liquidity": {"usd": 80_000}, + }, + { + "__typename": "VaultV2", + "name": "V2", + "totalAssetsUsd": 100_000, + "idleAssetsUsd": 0, + "liquidityUsd": 80_000, + "liquidityData": {"market": market}, + }, + ] + + _, liquidity, _ = calculate_combined_metrics(vaults) + + self.assertEqual(liquidity, 100_000) + + def test_v2_low_liquidity_alert_uses_graphql_liquidity(self) -> None: + vault = V2Vault( + name="Example", + address="0x" + "11" * 20, + chain=Chain.MAINNET, + asset_address="0x" + "22" * 20, + asset_symbol="USDC", + curator="", + owner="", + risk_level=1, + total_assets_usd=100_000, + liquidity_usd=500, + ) + + with patch("protocols.morpho.markets_v2.send_alert") as send: + check_low_liquidity(vault) + + alert = send.call_args.args[0] + self.assertIn("$500.00", alert.message) + self.assertIn("0.5%", alert.message) + + def test_zero_asset_group_is_retained_for_collateral_risk_check(self) -> None: + v1_vaults = [ + { + "__typename": "Vault", + "address": "0xe107cCdeb8e20E499545C813f98Cc90619b29859", + "name": "Yearn OG WBTC", + "chain": {"id": Chain.KATANA.chain_id}, + "asset": { + "address": "0x0913DA6Da4b42f538B445599b46Bb4622342Cf52", + "symbol": "vbWBTC", + }, + "state": {"totalAssetsUsd": 0}, + "liquidity": {"usd": 0}, + } + ] + + groups = get_yv_collateral_liquidity_by_asset(Chain.KATANA, v1_vaults, []) + wbtc_group = groups["0x0913da6da4b42f538b445599b46bb4622342cf52"] + + self.assertEqual(wbtc_group["combined_total_assets"], 0) + self.assertEqual(wbtc_group["combined_liquidity"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_morpho_v2_governance.py b/tests/test_morpho_v2_governance.py index 4715196..ae5bccf 100644 --- a/tests/test_morpho_v2_governance.py +++ b/tests/test_morpho_v2_governance.py @@ -1,10 +1,12 @@ import unittest -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch from eth_abi import encode as abi_encode from web3 import Web3 from protocols.morpho import governance_v2 +from protocols.morpho._shared import MorphoV2MonitoringError from protocols.morpho.governance_v2 import PendingConfig, V2GovernanceSnapshot from protocols.morpho.v2_decoders import submit_data_key from utils.chains import Chain @@ -17,8 +19,8 @@ def _selector(sig: str) -> bytes: return bytes(Web3.keccak(text=sig)[:4]) -def _build(sig: str, types: list[str], values: list) -> bytes: - return _selector(sig) + abi_encode(types, values) +def _build(sig: str, types: list[str], values: list[Any]) -> bytes: + return _selector(sig) + bytes(abi_encode(types, values)) def _snapshot(pending_configs: list[PendingConfig]) -> V2GovernanceSnapshot: @@ -26,7 +28,6 @@ def _snapshot(pending_configs: list[PendingConfig]) -> V2GovernanceSnapshot: name="Sentora PaypalUSD Main", address=Web3.to_checksum_address(VAULT), chain=Chain.MAINNET, - risk_level=3, owner="", curator="", sentinels=[], @@ -37,13 +38,13 @@ def _snapshot(pending_configs: list[PendingConfig]) -> V2GovernanceSnapshot: class TestMorphoV2GovernancePendingLabels(unittest.TestCase): - def test_resolved_pending_alert_uses_cached_function_name(self): + def test_resolved_pending_alert_uses_cached_function_name(self) -> None: state: dict[str, str] = {} - def read_value(_filename: str, key: str): + def read_value(_filename: str, key: str) -> str | int: return state.get(key, 0) - def write_value(_filename: str, key: str, value): + def write_value(_filename: str, key: str, value: object) -> None: state[key] = str(value) data = _build("addAdapter(address)", ["address"], [A1]) @@ -69,7 +70,7 @@ def write_value(_filename: str, key: str, value): self.assertNotIn(f"`{data_hash[:10]}…`", alert.message) self.assertIn("was executed", alert.message) - def test_resolved_pending_alert_without_cached_function_keeps_hash_only_message(self): + def test_resolved_pending_alert_without_cached_function_keeps_hash_only_message(self) -> None: data_hash = "3d6d72861e" + "0" * 54 with patch("protocols.morpho.governance_v2.send_alert") as send: @@ -80,5 +81,17 @@ def test_resolved_pending_alert_without_cached_function_keeps_hash_only_message( self.assertNotIn(f"(`{data_hash[:10]}…`)", alert.message) +class TestMorphoV2GovernanceFetch(unittest.TestCase): + def test_fetch_fails_if_api_omits_configured_vaults(self) -> None: + response = MagicMock() + response.json.return_value = {"data": {"vaultV2s": {"items": []}}} + + with ( + patch("protocols.morpho._shared.request_with_retry", return_value=response), + self.assertRaisesRegex(MorphoV2MonitoringError, "omitted configured Vault V2 governance"), + ): + governance_v2.fetch_governance_snapshots() + + if __name__ == "__main__": unittest.main()