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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/daily.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
MORPHO_FILENAME=cache-id-daily.txt
scripts: |
morpho/governance.py
morpho/governance_v2.py
ethena/ethena.py
strata/main.py
cap/liquidity.py
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/hourly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
scripts: |
3jane/main.py
morpho/markets.py
morpho/markets_v2.py
aave/main.py
lido/steth/main.py
lrt-pegs/curve/main.py
Expand Down
42 changes: 40 additions & 2 deletions morpho/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ To monitor a new Morpho vault, add its address to the `VAULTS_BY_CHAIN` variable

**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 some chains, the Morpho GraphQL API is not available. In this case, alternative script is used [markets_graph.py](./markets_graph.py) which uses The Graph API. Be aware that this script will require additional setup as it uses a different URL for vaults and markets, depending on the curator and frontend. Also, if the new chain is added, it will need new subgraph url define in [`GRAPH_BY_CHAIN`](./markets_graph.py#L29) variable.

### 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 [GitHub Actions](../.github/workflows/hourly.yml). The monitoring logic is implemented in [markets.py#L166](./markets.py#L166).
Expand Down Expand Up @@ -128,6 +126,46 @@ The system monitors the allocation ratio for each market hourly:

If any market's allocation exceeds its adjusted threshold, an alert is triggered with a corresponding Telegram message. This mechanism ensures that vaults maintain proper diversification and are not overly concentrated in higher-risk markets.

## Vault V2 Monitoring

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`.
- [`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.

To add a new v2 vault, append a row to the chain's list and pick a risk tier (1–5).

### Cache

Both scripts share `MORPHO_FILENAME` (default `cache-id.txt`). New key types added by v2:

| Key suffix | Segment | Value | Meaning |
| --- | --- | --- | --- |
| `v2_pending` | `keccak(data)` | `validAt` ts, `-1`, or `0` | Pending timelock operation: pending / executed / revoked |
| `v2_pending_index` | `pending_keys` | comma-joined `keccak(data)` | Reverse index used to detect operations that disappeared from `pendingConfigs` |
| `v2_role` | `owner` / `curator` | lowercase address | Last-known instant-role address |
| `v2_set` | `sentinels` / `allocators` / `adapters` | comma-joined lowercase addresses | Last-known role set |

### Limitations

`MorphoMarketV1AdapterV2` has its own internal timelock system (`setSkimRecipient`, `burnShares`, `increaseTimelock`, etc.). The Morpho GraphQL API does **not** expose adapter-internal pending operations, and replaying `Submit` events on each adapter would reintroduce the RPC cost we're explicitly avoiding. Phase-2 candidate.

### How to run locally

```bash
# Hourly (allocation + risk)
python morpho/markets_v2.py
# Daily (timelocks + role changes)
python morpho/governance_v2.py
```

Set `MORPHO_FILENAME=/tmp/morpho-cache.txt` to use an isolated cache while testing.

## API Docs

Morpho GraphQL API wizard is available at [https://api.morpho.org/graphql](https://api.morpho.org/graphql). GraphQL schema is available in [morpho_ql_schema.txt](./morpho_ql_schema.txt) file. For fetching a market oracle and validating it against RPC (including Chainlink, RedStone, Chronicle, API3), see [morpho-oracle-validation.md](./morpho-oracle-validation.md).
203 changes: 203 additions & 0 deletions morpho/_shared.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""Shared helpers used by both v1 and v2 Morpho monitors."""

from dataclasses import dataclass
from typing import Any, Dict, List, Optional

from utils.chains import Chain
from utils.http import request_with_retry
from utils.logging import get_logger

API_URL = "https://api.morpho.org/graphql"
MORPHO_URL = "https://app.morpho.org"

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 OG USDC", "0xB885F6d448dA7E2C642Ec31190B629E40E87B069", 3],
["Yearn OG WETH V2", "0xbe518068EB6135117207256F8C9aFf81B4382DB1", 2],
["Yearn USDC", "0xaA8d9E2aBa210639cE6C7cE21385e7c673ACa6f3", 2],
["OUSD Vault V2", "0xFB154c729A16802c4ad1E8f7FF539a8b9f49c960", 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", 3],
["d3nity dUSD V2", "0xf71da79a6eD40F894e883874dCE3f54B1493d930", 3],
],
}

SUPPORTED_CHAINS: List[Chain] = list(VAULTS_V2_BY_CHAIN.keys())


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()


def get_market_url(market_id: str, chain: Chain) -> str:
"""Build the Morpho UI URL for a market by its uniqueKey."""
return f"{MORPHO_URL}/{get_chain_name(chain)}/market/{market_id}"


def get_vault_url(vault_address: str, chain: Chain) -> str:
"""Build the Morpho UI URL for a vault by address."""
return f"{MORPHO_URL}/{get_chain_name(chain)}/vault/{vault_address}"


def fetch_market_name(market_id: str, chain: Chain) -> str:
"""Fetch a human-readable name like 'WBTC/USDC (86.00%)' for a market_id.

Falls back to the raw market_id on error so alerts always render.
"""
query = """
query GetMarket($uniqueKey: String!, $chainId: Int!) {
marketByUniqueKey(uniqueKey: $uniqueKey, chainId: $chainId) {
lltv
loanAsset { symbol, decimals }
collateralAsset { symbol }
}
}
"""
try:
response = request_with_retry(
"post",
API_URL,
json={"query": query, "variables": {"uniqueKey": market_id, "chainId": chain.chain_id}},
)
market = response.json()["data"]["marketByUniqueKey"]
collateral_symbol = market["collateralAsset"]["symbol"] if market.get("collateralAsset") else "idle"
loan_symbol = market["loanAsset"]["symbol"]
lltv_pct = int(market["lltv"]) / 1e18 * 100
return f"{collateral_symbol}/{loan_symbol} ({lltv_pct:.2f}%)"
except Exception as e:
logger.warning("Failed to fetch market name for %s: %s", market_id, e)
return market_id


@dataclass(frozen=True)
class Asset:
"""Token metadata as returned by Morpho GraphQL."""

address: str
symbol: str
decimals: Optional[int] = None


@dataclass(frozen=True)
class MarketState:
"""Per-market borrow/supply balances at the time of query."""

utilization: float
borrow_assets: int
supply_assets: int
borrow_assets_usd: float
supply_assets_usd: float


@dataclass(frozen=True)
class BadDebt:
"""Bad debt accrued on a market."""

underlying: int
usd: float


@dataclass(frozen=True)
class MarketMetrics:
"""State + bad debt for a single Morpho Blue market."""

unique_key: str
loan_asset: Asset
collateral_asset: Optional[Asset]
state: MarketState
bad_debt: BadDebt


def _parse_asset(raw: Optional[Dict[str, Any]]) -> Optional[Asset]:
if not raw:
return None
return Asset(
address=raw.get("address", ""),
symbol=raw.get("symbol", ""),
decimals=raw.get("decimals"),
)


def _parse_market_metrics(raw: Dict[str, Any]) -> MarketMetrics:
state_raw = raw.get("state") or {}
bad_debt_raw = raw.get("badDebt") or {}
loan_asset = _parse_asset(raw.get("loanAsset")) or Asset(address="", symbol="")
return MarketMetrics(
unique_key=raw["uniqueKey"],
loan_asset=loan_asset,
collateral_asset=_parse_asset(raw.get("collateralAsset")),
state=MarketState(
utilization=float(state_raw.get("utilization") or 0),
borrow_assets=int(state_raw.get("borrowAssets") or 0),
supply_assets=int(state_raw.get("supplyAssets") or 0),
borrow_assets_usd=float(state_raw.get("borrowAssetsUsd") or 0),
supply_assets_usd=float(state_raw.get("supplyAssetsUsd") or 0),
),
bad_debt=BadDebt(
underlying=int(bad_debt_raw.get("underlying") or 0),
usd=float(bad_debt_raw.get("usd") or 0),
),
)


def fetch_market_metrics(market_ids: List[str], chain: Chain) -> Dict[str, MarketMetrics]:
"""Fetch state + bad debt for a batch of market uniqueKeys.

Returns a dict keyed by lowercase market_id mapping to a ``MarketMetrics``
dataclass. Empty dict on GraphQL error or empty input.
"""
if not market_ids:
return {}

query = """
query GetMarkets($keys: [String!]!, $chainId: Int!) {
markets(where: { uniqueKey_in: $keys, chainId_in: [$chainId] }) {
items {
uniqueKey
loanAsset { address, symbol, decimals }
collateralAsset { address, symbol }
state {
utilization
borrowAssets
supplyAssets
borrowAssetsUsd
supplyAssetsUsd
}
badDebt { underlying, usd }
}
}
}
"""
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}}
)
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["uniqueKey"].lower(): _parse_market_metrics(item) for item in items}
Loading