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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion protocols/morpho/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ 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 `liquidityUsd` against the shared 1% threshold. V2 vaults used by YV-collateral strategies skip the individual liquidity threshold because `markets.py` performs the combined collateral-at-risk coverage check. Non-`MorphoMarketV1` adapters fail the run (configured vaults are market-adapter only).
- [`v2_decoders.py`](./v2_decoders.py) — selector→signature map and decoders for every v2 timelocked function (and the three `idData` tag prefixes used by `increaseAbsoluteCap`/`increaseRelativeCap`).
- [`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`.

### 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.

### Vault list

Expand Down
123 changes: 123 additions & 0 deletions protocols/morpho/_alerts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Per-vault alert grouping shared by the V1 and V2 Morpho governance monitors.

Both monitors used to send one Telegram message per finding, so a vault with
several simultaneous governance changes produced a burst of near-identical
messages that repeated the vault name and chain in every one. They now buffer
findings into a :class:`VaultDiff` and flush it as a single message per vault:
one header, one severity (the highest of the group), sections separated by
``---``.

The buffer also holds the cache writes that record "we alerted on this", so they
can be committed only once delivery succeeds — writing them during the diff pass
marks a change as alerted even when Telegram failed, and neither monitor retries
a message it has already recorded.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable, List

from utils.alert import Alert, AlertSeverity, send_alert
from utils.telegram import MAX_MESSAGE_LENGTH

# Ascending severity — a grouped alert is sent at the highest of its sections.
_SEVERITY_ORDER = (AlertSeverity.LOW, AlertSeverity.MEDIUM, AlertSeverity.HIGH, AlertSeverity.CRITICAL)

SECTION_SEPARATOR = "\n\n---\n\n"

# Telegram truncates past MAX_MESSAGE_LENGTH (and drops Markdown with it), so a
# large batch would silently lose its tail. We split into "(i/N)" parts instead.
# The slack covers the emoji ``send_alert`` prepends, the part suffix, and the
# blank line after the header.
_MESSAGE_OVERHEAD = 64


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

severity: AlertSeverity
body: str


@dataclass
class VaultDiff:
"""Buffered findings and cache writes for one vault's diff pass."""

alerts: List[VaultAlert] = field(default_factory=list)
writes: List[Callable[[], None]] = field(default_factory=list)

def alert(self, severity: AlertSeverity, body: str) -> None:
"""Buffer one section of the vault's grouped message."""
self.alerts.append(VaultAlert(severity, body))

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

def commit(self) -> None:
"""Persist every buffered cache write."""
for write in self.writes:
write()


def split_body(body: str, budget: int) -> List[str]:
"""Split one oversized section on line boundaries so nothing is truncated.

A single section can exceed the budget on its own — a batched multicall
submit renders one bullet per operation, and 30 of them do not fit. Splitting
between lines keeps every operation intact; only a single line longer than
the whole budget (which no rendered line comes close to) would still be cut
by Telegram.
"""
if len(body) <= budget:
return [body]
chunks: List[str] = []
current: List[str] = []
size = 0
for line in body.split("\n"):
cost = len(line) + 1
if current and size + cost > budget:
chunks.append("\n".join(current))
current = []
size = 0
current.append(line)
size += cost
if current:
chunks.append("\n".join(current))
return chunks


def split_into_messages(alerts: List[VaultAlert], budget: int) -> List[List[str]]:
"""Pack section bodies into groups that each fit within ``budget`` chars."""
parts: List[List[str]] = [[]]
size = 0
for alert in alerts:
for body in split_body(alert.body, budget):
cost = len(body) + len(SECTION_SEPARATOR)
if parts[-1] and size + cost > budget:
parts.append([])
size = 0
parts[-1].append(body)
size += cost
return parts


def send_vault_alerts(header: str, alerts: List[VaultAlert], protocol: str) -> None:
"""Send the buffered sections as one Telegram message, or "(i/N)" parts if long.

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.
"""
if not alerts:
return
severity = max((a.severity for a in alerts), key=_SEVERITY_ORDER.index)
parts = split_into_messages(alerts, MAX_MESSAGE_LENGTH - _MESSAGE_OVERHEAD - len(header))
total = len(parts)
for index, bodies in enumerate(parts, start=1):
suffix = f" ({index}/{total})" if total > 1 else ""
message = f"{header}{suffix}\n\n" + SECTION_SEPARATOR.join(bodies)
send_alert(Alert(severity, message, protocol))
88 changes: 47 additions & 41 deletions protocols/morpho/governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from web3 import Web3

from protocols.morpho._alerts import VaultDiff, send_vault_alerts
from protocols.morpho._shared import (
PROTOCOL,
MorphoMonitoringError,
Expand All @@ -14,7 +15,7 @@
)
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.alert import AlertSeverity
from utils.cache import (
get_last_executed_morpho_from_file,
write_last_executed_morpho_to_file,
Expand Down Expand Up @@ -171,8 +172,13 @@ def _load_market_governance_states(
return states


def _check_pending_cap(name: str, state: MarketGovernanceState, chain: Chain) -> None:
"""Alert once for a new pending V1 market cap."""
def _vault_header(name: str, vault_address: str, chain: Chain) -> str:
"""One-line header for the vault's grouped alert."""
return f"[{name}]({get_vault_url(vault_address, chain)}) on {chain.name}"


def _check_pending_cap(name: str, state: MarketGovernanceState, chain: Chain, diff: VaultDiff) -> None:
"""Buffer a section for a new pending V1 market cap."""
if state.pending_cap_timestamp <= 0:
return
last_timestamp = get_last_executed_morpho_from_file(
Expand All @@ -185,34 +191,31 @@ def _check_pending_cap(name: str, state: MarketGovernanceState, chain: Chain) ->
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}"
)
body = f"➕ Adding new market [{market_name}]({market_url}) with cap {pending_cap}.\nQueued 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}. "
body = (
f"📊 Updating cap for market [{market_name}]({market_url}): "
f"{current_cap} → {pending_cap}, difference: {difference:.2f}%.\n"
f"Queued for {queued_for}"
)
send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL))
write_last_executed_morpho_to_file(
diff.alert(AlertSeverity.MEDIUM, body)
diff.defer(
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."""
def _check_market_removal(state: MarketGovernanceState, chain: Chain, diff: VaultDiff) -> None:
"""Buffer a section for a newly queued V1 market removal."""
if state.removable_at <= 0:
return
last_timestamp = get_last_executed_morpho_from_file(
Expand All @@ -221,57 +224,55 @@ def _check_market_removal(name: str, state: MarketGovernanceState, chain: Chain)
REMOVABLE_AT_TYPE,
)
if state.removable_at <= last_timestamp:
logger.info("Skipping previously alerted market removal for %s market %s", name, state.market_id)
logger.info(
"Skipping previously alerted market removal for vault %s market %s",
state.vault_address,
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(
diff.alert(
AlertSeverity.MEDIUM,
f"➖ Queued to remove market [{market_name}]({market_url}) at {removable_at}",
)
diff.defer(
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:
def check_market_governance_state(name: str, state: MarketGovernanceState, chain: Chain, diff: VaultDiff) -> None:
"""Check pending cap and removal changes for one V1 market."""
_check_pending_cap(name, state, chain)
_check_market_removal(name, state, chain)
_check_pending_cap(name, state, chain, diff)
_check_market_removal(state, chain, diff)


def check_markets_pending_cap(name: str, morpho_contract: Any, chain: Chain, client: Any) -> None:
def check_markets_pending_cap(name: str, morpho_contract: Any, chain: Chain, client: Any, diff: VaultDiff) -> 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)
check_market_governance_state(name, state, chain, diff)


def check_pending_role_change(
name: str,
morpho_contract: Any,
role_type: str,
timestamp: int,
chain: Chain,
diff: VaultDiff,
) -> 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(morpho_contract.address, chain)
send_alert(
Alert(
AlertSeverity.HIGH,
f"{role_type.capitalize()} is changing for vault [{name}]({vault_url})",
PROTOCOL,
)
)
write_last_executed_morpho_to_file(morpho_contract.address, market_id, role_type, timestamp)
diff.alert(AlertSeverity.HIGH, f"🚨 {role_type.capitalize()} is changing")
diff.defer(write_last_executed_morpho_to_file, morpho_contract.address, market_id, role_type, timestamp)


def check_timelock_and_guardian(name: str, morpho_contract: Any, chain: Chain, client: Any) -> None:
def check_timelock_and_guardian(morpho_contract: Any, client: Any, diff: VaultDiff) -> None:
with morpho_contract.w3.batch_requests() as batch:
batch.add(morpho_contract.functions.pendingTimelock())
batch.add(morpho_contract.functions.pendingGuardian())
Expand All @@ -282,8 +283,8 @@ def check_timelock_and_guardian(name: str, morpho_contract: Any, chain: Chain, c
timelock = responses[0][1] # [1] to get the timestamp
guardian = responses[1][1] # [1] to get the timestamp

check_pending_role_change(name, morpho_contract, "timelock", timelock, chain)
check_pending_role_change(name, morpho_contract, "guardian", guardian, chain)
check_pending_role_change(morpho_contract, "timelock", timelock, diff)
check_pending_role_change(morpho_contract, "guardian", guardian, diff)


def get_data_for_chain(chain: Chain) -> None:
Expand All @@ -295,8 +296,13 @@ def get_data_for_chain(chain: Chain) -> None:

for vault in vaults:
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)
# Buffer every finding for this vault, then send them as one message and
# only then record them as alerted.
diff = VaultDiff()
check_markets_pending_cap(vault.name, morpho_contract, chain, client, diff)
check_timelock_and_guardian(morpho_contract, client, diff)
send_vault_alerts(_vault_header(vault.name, vault.address, chain), diff.alerts, PROTOCOL)
diff.commit()


def main() -> None:
Expand Down
Loading