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
6 changes: 4 additions & 2 deletions monitoring.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,11 @@ protocols:
- name: "Low Liquidity"
description: "V1 non-YV-collateral and all V2 vault liquidity <2%; combined V1/V2 YV-collateral unwind liquidity vs collateral at risk"
- name: "V1 Governance"
description: "Pending supply caps, market removals, timelock changes, guardian changes"
description: "Pending supply caps, market removals (CRITICAL while the vault still supplies the market), timelock decreases, guardian changes"
- name: "V2 Governance"
description: "Pending timelocked operations, owner/curator changes, sentinel/allocator/adapter changes"
description: "Pending timelocked operations (CRITICAL for removeAdapter of a funded adapter), owner/curator changes, sentinel/allocator/adapter changes"
- name: "V2 Adapter Share Burns"
description: "Pending MorphoMarketV1AdapterV2 burnShares (CRITICAL while the adapter holds the market) and burnShares timelock decreases, via eth_call"
- name: "V2 Wrapped V1 Coverage"
description: "V2 vault wrapping a v1 vault not in config.py:VAULTS_V1_BY_CHAIN"
- name: "Safe Multisig"
Expand Down
23 changes: 20 additions & 3 deletions protocols/morpho/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ Morpho governance monitoring is defined in the [Python script](./governance.py)

The script checks if there are any new values pending in the timelock for a given vault. It detects the following changes:

- Changing timelock value, minimal values is 1 day.
- Decreasing the timelock (increases apply instantly, so only decreases are ever pending), minimal value is 1 day. The HIGH alert shows the current → new value and when it becomes executable.
- Changing guardian address.
- Changing supply caps, only to higher value than the current one, for both supply and withdraw markets.
- Removing of a market from the vault.
- Removing of a market from the vault (`submitMarketRemoval`). Once `removableAt` passes, `updateWithdrawQueue` can drop the market **even if the vault still supplies it**, writing that supply off. The monitor reads the vault's Morpho Blue position in the market:
- any non-zero supply → **CRITICAL** potential-loss alert with the amount at risk and its share of `totalAssets` (omitted if `totalAssets()` reverts, e.g. on a broken IRM — the check itself still runs), re-alerted once within 24h of `removableAt`;
- no supply → MEDIUM (routine cleanup).

### How to Add a New Vault

Add a `VaultConfig` to `VAULTS_V1_BY_CHAIN` in [config.py](./config.py). Every configured V1 vault receives both market and governance monitoring. `governance.py` iterates every chain in that mapping, so a new chain needs no script change; a failure on one chain is logged and the remaining chains are still checked before the run fails.

Monitored chains: Mainnet, Base, Katana, HyperEVM, and Robinhood Chain (V2 only). HyperEVM uses `PROVIDER_URL_HYPEREVM` when set; if no `PROVIDER_URL_HYPEREVM*` variable exists it falls back to the public `https://rpc.hyperliquid.xyz/evm` (`PUBLIC_RPC_URLS` in [utils/chains.py](../../utils/chains.py)). RPC requests time out after 60 seconds so a stalled public endpoint cannot hang the daily profile.
Monitored chains: Mainnet, Base, Katana, HyperEVM, and Robinhood Chain (V2 only). HyperEVM uses `PROVIDER_URL_HYPEREVM` when set; if no `PROVIDER_URL_HYPEREVM*` variable exists it falls back to the public `https://rpc.hyperliquid.xyz/evm` (`PUBLIC_RPC_URLS` in [utils/chains.py](../../utils/chains.py)). Robinhood Chain likewise falls back to its official `https://rpc.mainnet.chain.robinhood.com`, used by the V2 adapter timelock reads. RPC requests time out after 60 seconds so a stalled public endpoint cannot hang the daily profile.

## Vaults & Markets

Expand Down Expand Up @@ -152,8 +154,23 @@ Morpho's [Vault V2](https://github.com/morpho-org/vault-v2) replaces the v1 sing

- [`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, GraphQL-only (no RPC): one `vaultV2s` query loads TVL, liquidity, and `MorphoMarketV1` adapter positions for every configured vault, then one `markets` query per chain loads state/bad debt. Applies the shared [risk.py](./risk.py) policy using each position's `supplyAssetsUsd`, and checks withdrawable liquidity (idle plus the vault's position in the liquidity adapter's market, capped by market cash) against the 2% V2 threshold for every vault, including YV-collateral ones, which `markets.py` also covers with the combined collateral-at-risk check. Non-`MorphoMarketV1` adapters fail the run (configured vaults are market-adapter only).
- [`adapter_timelocks_v2.py`](./adapter_timelocks_v2.py) — run by `governance_v2.py`, reads each `MorphoMarketV1AdapterV2`'s own timelock on-chain with batched `eth_call`s (no `eth_getLogs`), since the Morpho GraphQL API has no adapter-level timelock data. A pending `burnShares(marketId)` writes off the adapter's supply in that market (the V2 analogue of a V1 market removal): `executableAt(burnShares(marketId))` is read for every held market → CRITICAL with the amount at risk (stored shares at Morpho Blue market totals, never the IRM, which may revert). A drop in `timelock(burnShares)` → HIGH. A failed RPC read still lets the GraphQL diff run, then fails the job.
- **TODO:** a burn submitted with non-canonical calldata (canonical + trailing bytes; Solidity ignores them, but `executableAt` is keyed by the exact bytes) is not detected. It needs the adapter's `Submit` events, which the Morpho API does not expose.
- [`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`). Absolute caps are denominated in the *vault's* asset whatever id they are keyed by, so the decoder takes the vault's asset decimals/symbol; `type(uint128).max` renders as `unlimited`.

### Potential vault loss alerts

Operations that can write off vault funds are **CRITICAL** for any non-zero amount, sent as their own message prefixed `🚨 POTENTIAL VAULT LOSS` (never bundled into the grouped diff), and re-alerted once when within 24 hours of execution:

| Vault | Operation | Condition |
|---|---|---|
| V1 | `submitMarketRemoval` | the vault still supplies the market |
| V2 | `removeAdapter(adapter)` | the adapter holds assets (`totalAssets` is the sum of adapter `realAssets`) |
| V2 | `decreaseTimelock(removeAdapter, …)` | any adapter holds assets |
| V2 adapter | `burnShares(marketId)` | the adapter holds assets in the market |

Other V2 timelock decreases and `abdicate` (irreversible) are HIGH; remaining pending operations stay MEDIUM. Pending V2 operations can be cancelled by the curator or a sentinel with `revoke(data)`; V1 removals by the curator or guardian with `revokePendingMarketRemoval`.

### Grouped alerts

Both governance monitors buffer their findings per vault in [`_alerts.py`](./_alerts.py) and send them as **one Telegram message per vault** — one header naming the vault and chain, one severity (the highest of the group), sections separated by `---`. A vault with several simultaneous changes no longer produces a burst of near-identical messages. Groups too long for a single message split into numbered `(i/N)` parts rather than being truncated, and the cache cursors that record "we alerted on this" are committed only after the send succeeds, so a Telegram failure is retried on the next run instead of being lost.
Expand Down
31 changes: 30 additions & 1 deletion protocols/morpho/_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,26 @@
_MESSAGE_OVERHEAD = 64


# Header prefix for findings that can write off vault funds (market removal,
# adapter removal, share burn). They are sent as their own message so they never
# drown in a routine grouped diff.
LOSS_PREFIX = "🚨 POTENTIAL VAULT LOSS"

# Pending loss operations are re-alerted once when their execution time is this close.
REMINDER_WINDOW_SECONDS = 24 * 60 * 60


@dataclass
class VaultAlert:
"""One section of a vault's grouped Telegram message."""
"""One section of a vault's grouped Telegram message.

``standalone`` sections are sent as their own message with ``LOSS_PREFIX``
instead of being grouped with the vault's other findings.
"""

severity: AlertSeverity
body: str
standalone: bool = False


@dataclass
Expand All @@ -53,6 +67,10 @@ def alert(self, severity: AlertSeverity, body: str) -> None:
"""Buffer one section of the vault's grouped message."""
self.alerts.append(VaultAlert(severity, body))

def loss(self, body: str) -> None:
"""Buffer a CRITICAL potential-loss finding, sent as its own message."""
self.alerts.append(VaultAlert(AlertSeverity.CRITICAL, body, standalone=True))

def defer(self, write: Callable[..., Any], *args: Any) -> None:
"""Buffer a cache write to apply once the alert is delivered."""
self.writes.append(partial(write, *args))
Expand Down Expand Up @@ -111,7 +129,18 @@ def send_vault_alerts(header: str, alerts: List[VaultAlert], protocol: str) -> N
No-op when ``alerts`` is empty so callers don't have to guard. Every part
carries the same header and the highest severity of the whole group, so a
LOW section bundled with an owner change still pings the channel.

Standalone (potential-loss) sections go out first, one message each, under a
``LOSS_PREFIX`` header; the remaining sections are grouped as usual.
"""
for alert in alerts:
if alert.standalone:
_send_group(f"{LOSS_PREFIX} — {header}", [alert], protocol)
_send_group(header, [a for a in alerts if not a.standalone], protocol)


def _send_group(header: str, alerts: List[VaultAlert], protocol: str) -> None:
"""Send sections under one header at their highest severity, split into parts if long."""
if not alerts:
return
severity = max((a.severity for a in alerts), key=_SEVERITY_ORDER.index)
Expand Down
63 changes: 63 additions & 0 deletions protocols/morpho/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

import time
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional

import requests

from utils.chains import Chain
from utils.config import Config
from utils.formatting import format_duration, format_token_amount, format_with_suffix
from utils.http_client import request_with_retry
from utils.logger import get_logger

Expand Down Expand Up @@ -81,6 +83,67 @@ def execute_graphql(
raise error_type(f"Morpho GraphQL failed after {retries + 1} attempts for {context}")


MORPHO_BLUE_ABI_PATH = "protocols/morpho/abi/morpho_blue.json"

# Morpho Blue SharesMathLib virtual offsets, used to convert supply shares to assets.
VIRTUAL_SHARES = 10**6
VIRTUAL_ASSETS = 1


def to_assets_down(shares: int, total_assets: int, total_shares: int) -> int:
"""Convert Morpho Blue supply shares to assets, rounding down like ``SharesMathLib``.

Uses the market's stored totals rather than an IRM-accrued view, so it can't
revert on a market whose IRM reverts; it only omits interest since the last update.
"""
return shares * (total_assets + VIRTUAL_ASSETS) // (total_shares + VIRTUAL_SHARES)


def format_ts(ts: int) -> str:
"""Render a unix timestamp as local ``YYYY-MM-DD HH:MM:SS``."""
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")


def format_countdown(ts: int) -> str:
"""Human-friendly time remaining until ``ts``, e.g. ``(3 days)``."""
seconds = ts - int(datetime.now().timestamp())
if seconds <= 0:
return "(now)"
days = seconds / 86400
if days >= 1:
n = round(days)
return f"({n} day{'s' if n != 1 else ''})"
hours = seconds / 3600
if hours >= 1:
n = round(hours)
return f"({n} hour{'s' if n != 1 else ''})"
n = max(round(seconds / 60), 1)
return f"({n} minute{'s' if n != 1 else ''})"


def format_timelock(seconds: int) -> str:
"""Render a timelock duration; a zero timelock reads ``0s`` rather than ``now``."""
return format_duration(seconds) if seconds > 0 else "0s"


def format_amount_at_risk(raw: int, decimals: int | None, symbol: str | None, total_assets: int | None) -> str:
"""Render an at-risk amount with its share of the vault, e.g. ``1.20M USDC (12.50% of vault)``.

Dust that would round to ``0.00`` is shown in raw units instead, because any
non-zero amount at risk is worth seeing exactly.
"""
if decimals is None:
amount = f"{raw:,} (raw units)"
else:
human = format_token_amount(raw, decimals)
amount = str(format_with_suffix(human)) if human >= 0.01 else f"{raw:,} raw units"
if symbol:
amount = f"{amount} {symbol}"
if total_assets:
amount = f"{amount} ({raw / total_assets:.2%} of vault)"
return amount


def require_configured_keys(
expected: Iterable[str],
found: Iterable[str],
Expand Down
30 changes: 30 additions & 0 deletions protocols/morpho/abi/morpho_blue.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[
{
"inputs": [
{ "internalType": "Id", "name": "", "type": "bytes32" },
{ "internalType": "address", "name": "", "type": "address" }
],
"name": "position",
"outputs": [
{ "internalType": "uint256", "name": "supplyShares", "type": "uint256" },
{ "internalType": "uint128", "name": "borrowShares", "type": "uint128" },
{ "internalType": "uint128", "name": "collateral", "type": "uint128" }
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [{ "internalType": "Id", "name": "", "type": "bytes32" }],
"name": "market",
"outputs": [
{ "internalType": "uint128", "name": "totalSupplyAssets", "type": "uint128" },
{ "internalType": "uint128", "name": "totalSupplyShares", "type": "uint128" },
{ "internalType": "uint128", "name": "totalBorrowAssets", "type": "uint128" },
{ "internalType": "uint128", "name": "totalBorrowShares", "type": "uint128" },
{ "internalType": "uint128", "name": "lastUpdate", "type": "uint128" },
{ "internalType": "uint128", "name": "fee", "type": "uint128" }
],
"stateMutability": "view",
"type": "function"
}
]
Loading
Loading