diff --git a/protocols/morpho/README.md b/protocols/morpho/README.md index 3b6204e8..dcc66374 100644 --- a/protocols/morpho/README.md +++ b/protocols/morpho/README.md @@ -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 diff --git a/protocols/morpho/_alerts.py b/protocols/morpho/_alerts.py new file mode 100644 index 00000000..2eb2e4d4 --- /dev/null +++ b/protocols/morpho/_alerts.py @@ -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)) diff --git a/protocols/morpho/governance.py b/protocols/morpho/governance.py index 4f554e5b..3a29b692 100644 --- a/protocols/morpho/governance.py +++ b/protocols/morpho/governance.py @@ -4,6 +4,7 @@ from web3 import Web3 +from protocols.morpho._alerts import VaultDiff, send_vault_alerts from protocols.morpho._shared import ( PROTOCOL, MorphoMonitoringError, @@ -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, @@ -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( @@ -185,25 +191,22 @@ 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, @@ -211,8 +214,8 @@ def _check_pending_cap(name: str, state: MarketGovernanceState, chain: Chain) -> ) -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( @@ -221,16 +224,22 @@ 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, @@ -238,40 +247,32 @@ def _check_market_removal(name: str, state: MarketGovernanceState, chain: Chain) ) -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()) @@ -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: @@ -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: diff --git a/protocols/morpho/governance_v2.py b/protocols/morpho/governance_v2.py index 722abb14..d4646844 100644 --- a/protocols/morpho/governance_v2.py +++ b/protocols/morpho/governance_v2.py @@ -27,6 +27,7 @@ from web3 import Web3 +from protocols.morpho._alerts import VaultDiff, send_vault_alerts from protocols.morpho._shared import ( PROTOCOL, MorphoV2MonitoringError, @@ -36,7 +37,7 @@ ) 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.alert import AlertSeverity from utils.cache import ( get_last_value_for_key_from_file, morpho_filename, @@ -45,7 +46,6 @@ ) from utils.chains import Chain from utils.logger import get_logger -from utils.telegram import MAX_MESSAGE_LENGTH logger = get_logger("morpho.governance_v2") @@ -70,6 +70,7 @@ address name chain { id } + asset { symbol decimals } owner { address } curator { address } sentinels { sentinel { address } } @@ -117,6 +118,9 @@ class V2GovernanceSnapshot: allocators: List[str] adapters: List[str] pending_configs: List[PendingConfig] = field(default_factory=list) + # The vault's own asset — denominates absolute caps in decoded operations. + asset_symbol: str = "" + asset_decimals: int | None = None # ---------------------------------------------------------------------------- @@ -130,6 +134,12 @@ def _hex_to_bytes(value: str) -> bytes: return bytes.fromhex(value) +def _asset_decimals(item: Dict[str, Any]) -> int | None: + """Return the vault asset's decimals, or None when the API omits them.""" + raw = (item.get("asset") or {}).get("decimals") + return int(raw) if raw is not None else None + + def _checksum_or_empty(value: str) -> str: if not value: return "" @@ -191,6 +201,8 @@ def fetch_governance_snapshots() -> Dict[Chain, List[V2GovernanceSnapshot]]: allocators=sorted(allocators), adapters=sorted(adapters), pending_configs=pending, + asset_symbol=(item.get("asset") or {}).get("symbol") or "", + asset_decimals=_asset_decimals(item), ) ) @@ -255,7 +267,12 @@ def _explorer_link(chain: Chain, tx_hash: str) -> str: def _operation_label(snapshot: V2GovernanceSnapshot, pc: PendingConfig) -> str: - decoded = decode_submit(pc.data, snapshot.chain) + decoded = decode_submit( + pc.data, + snapshot.chain, + asset_decimals=snapshot.asset_decimals, + asset_symbol=snapshot.asset_symbol or None, + ) if decoded: return str(decoded) return pc.function_name or f"`{pc.data_hash[:10]}…`" @@ -273,58 +290,6 @@ def _pending_function_key(snapshot: V2GovernanceSnapshot, data_hash: str) -> str return str(morpho_key(snapshot.address.lower(), data_hash, PENDING_FUNCTION_TYPE)) -@dataclass -class _VaultAlert: - """One section of a vault's grouped Telegram message.""" - - severity: AlertSeverity - body: str - - -@dataclass -class _VaultDiff: - """Buffered output of one vault's diff pass: alert sections and cache writes. - - Each diff category (``_diff_pending``, ``_diff_single_role``, ``_diff_set``) - appends here instead of sending immediately, so a vault with new pending - configs, an owner change, and an adapter swap arrives as one message rather - than one per category. - - Cache writes are buffered too and committed only after the send succeeds - (see ``diff_and_alert``). Writing them during the diff pass would mark a - change as alerted even when Telegram failed, and ``main`` turns that into a - logged failure — the alert itself would never be retried. - """ - - alerts: List[_VaultAlert] = field(default_factory=list) - writes: List[tuple[str, Any]] = 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 write(self, key: str, value: Any) -> None: - """Buffer a cache write to apply once the alert is delivered.""" - self.writes.append((key, value)) - - def commit(self) -> None: - """Persist every buffered cache write.""" - for key, value in self.writes: - _write(key, value) - - -# 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 - - def _vault_header(snapshot: V2GovernanceSnapshot) -> str: """One-line header for the grouped alert: ``V2 [name](url) on chain``.""" return f"V2 [{snapshot.name}]({get_vault_url(snapshot.address, snapshot.chain)}) on {snapshot.chain.name}" @@ -350,53 +315,10 @@ def _split_body(body: str, budget: int) -> List[str]: 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. - - Oversized sections are split too, so every character is handed to Telegram - instead of allowing its client-side length guard to truncate the tail. - """ - parts: List[List[str]] = [] - current: List[str] = [] - size = 0 - for alert in alerts: - for body in _split_body(alert.body, budget): - separator_size = len(_SECTION_SEPARATOR) if current else 0 - if current and size + separator_size + len(body) > budget: - parts.append(current) - current = [] - size = 0 - separator_size = 0 - current.append(body) - size += separator_size + len(body) - if current: - parts.append(current) - return parts - - -def _send_vault_alerts(snapshot: V2GovernanceSnapshot, alerts: List[_VaultAlert]) -> 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) - header = _vault_header(snapshot) - 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)) - - def _alert_pending_new( snapshot: V2GovernanceSnapshot, pending: List[tuple[PendingConfig, str]], - diff: _VaultDiff, + diff: VaultDiff, ) -> None: """Buffer a section for newly-submitted timelocked operation(s) on one vault. @@ -440,7 +362,7 @@ def _alert_pending_resolved( data_hash: str, last_valid_at: int, function_name: str, - diff: _VaultDiff, + diff: VaultDiff, ) -> None: """Buffer a section for a pending operation that left ``pendingConfigs``. @@ -458,12 +380,12 @@ def _alert_pending_resolved( ) -def _alert_role_change(role: str, before: str, after: str, diff: _VaultDiff) -> None: +def _alert_role_change(role: str, before: str, after: str, diff: VaultDiff) -> None: icon = "👑" if role == "owner" else "🎩" diff.alert(AlertSeverity.HIGH, f"🚨 {icon} {role.capitalize()} changed: `{before}` → `{after}`") -def _alert_set_diff(set_name: str, added: set[str], removed: set[str], diff: _VaultDiff) -> None: +def _alert_set_diff(set_name: str, added: set[str], removed: set[str], diff: VaultDiff) -> None: icon = {"sentinels": "🛡️", "allocators": "🎯", "adapters": "🧩"}.get(set_name, "ℹ️") lines: list[str] = [] for addr in sorted(added): @@ -478,7 +400,7 @@ def _alert_set_diff(set_name: str, added: set[str], removed: set[str], diff: _Va # ---------------------------------------------------------------------------- -def _diff_pending(snapshot: V2GovernanceSnapshot, diff: _VaultDiff) -> None: +def _diff_pending(snapshot: V2GovernanceSnapshot, diff: VaultDiff) -> None: addr = snapshot.address.lower() current_keys: set[str] = set() @@ -486,14 +408,14 @@ def _diff_pending(snapshot: V2GovernanceSnapshot, diff: _VaultDiff) -> None: for pc in snapshot.pending_configs: current_keys.add(pc.data_hash) operation_label = _operation_label(snapshot, pc) - diff.write(_pending_function_key(snapshot, pc.data_hash), _operation_function_name(pc, operation_label)) + diff.defer(_write, _pending_function_key(snapshot, pc.data_hash), _operation_function_name(pc, operation_label)) cache_key = morpho_key(addr, pc.data_hash, PENDING_TYPE) last = _read_int(cache_key) # Already alerted at this validAt, or marked executed. if last == pc.valid_at or last == EXECUTED: continue new_pending.append((pc, operation_label)) - diff.write(cache_key, pc.valid_at) + diff.defer(_write, cache_key, pc.valid_at) # Group all newly-submitted operations for this vault into one section. _alert_pending_new(snapshot, new_pending, diff) @@ -513,21 +435,21 @@ def _diff_pending(snapshot: V2GovernanceSnapshot, diff: _VaultDiff) -> None: # Already marked executed/revoked. continue _alert_pending_resolved(data_hash, last, _read_str(_pending_function_key(snapshot, data_hash)), diff) - diff.write(cache_key, EXECUTED if last <= int(datetime.now().timestamp()) else REVOKED) + diff.defer(_write, cache_key, EXECUTED if last <= int(datetime.now().timestamp()) else REVOKED) - diff.write(index_key, ",".join(sorted(current_keys))) + diff.defer(_write, index_key, ",".join(sorted(current_keys))) -def _diff_single_role(snapshot: V2GovernanceSnapshot, role: str, current: str, diff: _VaultDiff) -> None: +def _diff_single_role(snapshot: V2GovernanceSnapshot, role: str, current: str, diff: VaultDiff) -> None: cache_key = morpho_key(snapshot.address.lower(), role, ROLE_TYPE) last = _read_str(cache_key) cur_lc = current.lower() if last and last != cur_lc: _alert_role_change(role, last, current, diff) - diff.write(cache_key, cur_lc) + diff.defer(_write, cache_key, cur_lc) -def _diff_set(snapshot: V2GovernanceSnapshot, set_name: str, current: List[str], diff: _VaultDiff) -> None: +def _diff_set(snapshot: V2GovernanceSnapshot, set_name: str, current: List[str], diff: VaultDiff) -> None: cache_key = morpho_key(snapshot.address.lower(), set_name, SET_TYPE) last_str = _read_str(cache_key) last_set = {a for a in last_str.split(",") if a} if last_str else set() @@ -539,7 +461,7 @@ def _diff_set(snapshot: V2GovernanceSnapshot, set_name: str, current: List[str], added_cs: set[str] = {str(Web3.to_checksum_address(a)) for a in added} removed_cs: set[str] = {str(Web3.to_checksum_address(a)) for a in removed} _alert_set_diff(set_name, added_cs, removed_cs, diff) - diff.write(cache_key, ",".join(sorted(current_set))) + diff.defer(_write, cache_key, ",".join(sorted(current_set))) def diff_and_alert(snapshot: V2GovernanceSnapshot) -> None: @@ -556,14 +478,14 @@ def diff_and_alert(snapshot: V2GovernanceSnapshot) -> None: fails) therefore repeats the whole group next run — duplicates beat a governance change nobody ever sees. """ - diff = _VaultDiff() + diff = VaultDiff() _diff_pending(snapshot, diff) _diff_single_role(snapshot, "owner", snapshot.owner, diff) _diff_single_role(snapshot, "curator", snapshot.curator, diff) _diff_set(snapshot, "sentinels", snapshot.sentinels, diff) _diff_set(snapshot, "allocators", snapshot.allocators, diff) _diff_set(snapshot, "adapters", snapshot.adapters, diff) - _send_vault_alerts(snapshot, diff.alerts) + send_vault_alerts(_vault_header(snapshot), diff.alerts, PROTOCOL) diff.commit() diff --git a/protocols/morpho/v2_decoders.py b/protocols/morpho/v2_decoders.py index 3f05722a..7a616f1f 100644 --- a/protocols/morpho/v2_decoders.py +++ b/protocols/morpho/v2_decoders.py @@ -28,6 +28,10 @@ WAD = 10**18 +# V2 caps are uint128, so type(uint128).max means "no cap". Rendering it through +# the decimals formatter produces a meaningless 340-undecillion figure. +UNCAPPED = 2**128 - 1 + # Timelocked selectors on VaultV2 (per VaultV2.sol). All require curator submit. _VAULT_V2_SIGS: list[str] = [ "setIsAllocator(address,bool)", @@ -199,10 +203,20 @@ def _format_cap_value( """ if is_relative: return _format_wad_pct(new_cap) + if new_cap >= UNCAPPED: + return "unlimited" return _format_cap_amount(new_cap, decimals, symbol) -def _format_cap_change(id_data: bytes, new_cap: int, chain: Chain | None = None, *, is_relative: bool = False) -> str: +def _format_cap_change( + id_data: bytes, + new_cap: int, + chain: Chain | None = None, + *, + is_relative: bool = False, + asset_decimals: int | None = None, + asset_symbol: str | None = None, +) -> str: market_params_type = "(address,address,address,address,uint256)" if chain is not None: try: @@ -224,8 +238,16 @@ def _format_cap_change(id_data: bytes, new_cap: int, chain: Chain | None = None, return f"market [{metadata['name']}]({get_market_url(market_id, chain)}) → cap {cap}" # Fallback path (collateralToken / adapter id, or missing market metadata). - # Absolute caps here have no resolvable denomination, so show the raw value. - cap = _format_wad_pct(new_cap) if is_relative else f"{new_cap}" + # Every V2 cap — whatever the id it is keyed by — limits the vault's own + # allocation, so it is denominated in the vault's asset rather than in the + # collateral token the id names. Without that asset we can only show the raw + # integer, which reads as a nonsense number for a 6-decimal vault. + cap = _format_cap_value( + new_cap, + is_relative=is_relative, + decimals=asset_decimals, + symbol=asset_symbol, + ) return f"{decode_id_data(id_data, chain)} → cap {cap}" @@ -239,7 +261,14 @@ def _encode_market_params(loan: str, collateral: str, oracle: str, irm: str, llt ) -def _format_args(sig: str, args: tuple[Any, ...], chain: Chain | None = None) -> str: # noqa: PLR0911,PLR0912 +def _format_args( # noqa: PLR0911,PLR0912 + sig: str, + args: tuple[Any, ...], + chain: Chain | None = None, + *, + asset_decimals: int | None = None, + asset_symbol: str | None = None, +) -> str: """Render a decoded argument tuple per signature.""" name = _function_name(sig) @@ -264,7 +293,14 @@ def _format_args(sig: str, args: tuple[Any, ...], chain: Chain | None = None) -> return _format_address(addr) if name in ("increaseAbsoluteCap", "increaseRelativeCap"): id_data, new_cap = args - return _format_cap_change(id_data, new_cap, chain, is_relative=(name == "increaseRelativeCap")) + return _format_cap_change( + id_data, + new_cap, + chain, + is_relative=(name == "increaseRelativeCap"), + asset_decimals=asset_decimals, + asset_symbol=asset_symbol, + ) if name in ("increaseTimelock", "decreaseTimelock"): sel_bytes, duration = args return f"{_resolve_inner_selector(sel_bytes)} → {duration}s" @@ -295,9 +331,19 @@ def _arg_types(sig: str) -> list[str]: return [t.strip() for t in inner.split(",")] -def decode_submit(data: bytes, chain: Chain | None = None) -> str: +def decode_submit( + data: bytes, + chain: Chain | None = None, + *, + asset_decimals: int | None = None, + asset_symbol: str | None = None, +) -> str: """Render a Submit/Accept/Revoke ``data`` payload as a function call string. + ``asset_decimals`` / ``asset_symbol`` describe the submitting vault's own + asset. They denominate absolute caps whose id is not a market (e.g. a + ``collateralToken`` id), which otherwise render as a raw integer. + Falls back to a Sourcify 4byte lookup for unknown selectors so we never crash on novel selectors. """ @@ -320,7 +366,8 @@ def decode_submit(data: bytes, chain: Chain | None = None) -> str: logger.warning("Failed to ABI-decode %s payload: %s", sig, e) return f"{_function_name(sig)}()" - return f"{_function_name(sig)}({_format_args(sig, args, chain)})" + formatted = _format_args(sig, args, chain, asset_decimals=asset_decimals, asset_symbol=asset_symbol) + return f"{_function_name(sig)}({formatted})" def submit_data_key(data: bytes) -> str: diff --git a/tests/test_morpho_governance.py b/tests/test_morpho_governance.py index dde8e461..82d31483 100644 --- a/tests/test_morpho_governance.py +++ b/tests/test_morpho_governance.py @@ -1,35 +1,47 @@ """Behavior tests for Morpho Vault V1 governance monitoring.""" import unittest +from typing import Any from unittest.mock import patch from protocols.morpho import governance +from protocols.morpho._alerts import VaultDiff from protocols.morpho.governance import MarketGovernanceState from utils.chains import Chain +VAULT = "0x" + "11" * 20 + + +def _state(market_id: str, **overrides: int) -> MarketGovernanceState: + values: dict[str, Any] = { + "pending_cap": 2_000_000, + "pending_cap_timestamp": 2_000_000_000, + "current_cap": 1_000_000, + "removable_at": 0, + } + values.update(overrides) + return MarketGovernanceState(vault_address=VAULT, market_id=market_id, **values) + 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, - ) + state = _state("0x" + "ab" * 32) + diff = VaultDiff() 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) + governance.check_market_governance_state("Example", state, Chain.MAINNET, diff) + # Writes are deferred until the message is delivered. + write.assert_not_called() + diff.commit() - alert = send.call_args.args[0] - self.assertIn("WETH/USDC (86.00%)", alert.message) - self.assertIn("difference: 100.00%", alert.message) + self.assertEqual(len(diff.alerts), 1) + body = diff.alerts[0].body + self.assertIn("WETH/USDC (86.00%)", body) + self.assertIn("difference: 100.00%", body) write.assert_called_once_with( state.vault_address, state.market_id, @@ -38,14 +50,10 @@ def test_new_pending_cap_alert_uses_shared_market_metadata(self) -> None: ) 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, + state = _state( + "0x" + "ab" * 32, pending_cap=0, pending_cap_timestamp=0, current_cap=0, removable_at=2_000_000_000 ) + diff = VaultDiff() with ( patch( @@ -53,13 +61,53 @@ def test_previously_alerted_market_removal_is_not_repeated(self) -> None: 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) + governance.check_market_governance_state("Example", state, Chain.MAINNET, diff) + diff.commit() - send.assert_not_called() + self.assertEqual(diff.alerts, []) write.assert_not_called() +class TestMorphoV1GovernanceGrouping(unittest.TestCase): + def test_findings_for_one_vault_collapse_into_a_single_message(self) -> None: + """Two new markets on one vault produce one message, not two. + + The vault name and chain move to the header, so the sections carry only + what differs between them. + """ + states = [ + _state("0x" + "ab" * 32, current_cap=0), + _state("0x" + "cd" * 32, current_cap=0), + ] + diff = VaultDiff() + market_names = iter([("cbETH/USDC (86.00%)", 6), ("cbETH/USDC (77.00%)", 6)]) + + sent: list[Any] = [] + with ( + patch("protocols.morpho.governance.get_last_executed_morpho_from_file", return_value=0), + patch("protocols.morpho.governance.fetch_market_info", side_effect=lambda *_: next(market_names)), + patch("protocols.morpho.governance.write_last_executed_morpho_to_file"), + patch("protocols.morpho._alerts.send_alert", side_effect=sent.append), + ): + for state in states: + governance.check_market_governance_state("Yearn OG USDC", state, Chain.BASE, diff) + governance.send_vault_alerts( + governance._vault_header("Yearn OG USDC", VAULT, Chain.BASE), + diff.alerts, + governance.PROTOCOL, + ) + + self.assertEqual(len(sent), 1, f"expected 1 grouped alert, got {len(sent)}") + message = sent[0].message + # Header names the vault and chain exactly once. + self.assertEqual(message.count("Yearn OG USDC"), 1) + self.assertEqual(message.count("on BASE"), 1) + # Both markets are present as separate sections. + self.assertIn("cbETH/USDC (86.00%)", message) + self.assertIn("cbETH/USDC (77.00%)", message) + self.assertEqual(message.count("Adding new market"), 2) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_morpho_v2_decoders.py b/tests/test_morpho_v2_decoders.py index 4e30dad1..2805604c 100644 --- a/tests/test_morpho_v2_decoders.py +++ b/tests/test_morpho_v2_decoders.py @@ -192,7 +192,9 @@ def test_increase_absolute_cap_with_market_params(self): decoded = decode_submit(data) self.assertIn("increaseAbsoluteCap", decoded) self.assertIn("lltv 91.00%", decoded) - self.assertIn(f"cap {1_000_000 * 10**6}", decoded) + # No chain, so no market metadata and no vault asset: the raw amount is + # all we can show, grouped for readability. + self.assertIn(f"cap {1_000_000 * 10**6:,}", decoded) def test_increase_absolute_cap_with_market_params_and_chain_links_market(self): market_params = (A1, A2, A3, A4, 91 * 10**16) diff --git a/tests/test_morpho_v2_governance.py b/tests/test_morpho_v2_governance.py index d5926849..3d40f64b 100644 --- a/tests/test_morpho_v2_governance.py +++ b/tests/test_morpho_v2_governance.py @@ -10,6 +10,7 @@ from protocols.morpho.governance_v2 import PendingConfig, V2GovernanceSnapshot from protocols.morpho.v2_decoders import submit_data_key from utils.chains import Chain +from utils.telegram import MAX_MESSAGE_LENGTH A1 = "0x" + "11" * 20 VAULT = "0x" + "aa" * 20 @@ -56,7 +57,7 @@ def write_value(_filename: str, key: str, value: object) -> None: with ( patch("protocols.morpho.governance_v2.get_last_value_for_key_from_file", side_effect=read_value), patch("protocols.morpho.governance_v2.write_last_value_to_file", side_effect=write_value), - patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append), + patch("protocols.morpho._alerts.send_alert", side_effect=sent.append), ): # First run: the pending config appears and is alerted as a Submit. governance_v2.diff_and_alert(_snapshot([pc])) @@ -77,12 +78,12 @@ def write_value(_filename: str, key: str, value: object) -> None: def test_resolved_pending_alert_without_cached_function_keeps_hash_only_message(self) -> None: data_hash = "3d6d72861e" + "0" * 54 snapshot = _snapshot([]) - diff = governance_v2._VaultDiff() + diff = governance_v2.VaultDiff() governance_v2._alert_pending_resolved(data_hash, 1, "", diff) sent: list[Any] = [] - with patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append): - governance_v2._send_vault_alerts(snapshot, diff.alerts) + with patch("protocols.morpho._alerts.send_alert", side_effect=sent.append): + governance_v2.send_vault_alerts(governance_v2._vault_header(snapshot), diff.alerts, governance_v2.PROTOCOL) self.assertEqual(len(sent), 1) message = sent[0].message @@ -111,7 +112,7 @@ def write_value(_filename: str, key: str, value: object) -> None: with ( patch("protocols.morpho.governance_v2.get_last_value_for_key_from_file", side_effect=read_value), patch("protocols.morpho.governance_v2.write_last_value_to_file", side_effect=write_value), - patch("protocols.morpho.governance_v2.send_alert") as send, + patch("protocols.morpho._alerts.send_alert") as send, ): governance_v2.diff_and_alert(_snapshot(pcs)) @@ -137,7 +138,7 @@ def test_single_new_pending_uses_unnumbered_format(self) -> None: "protocols.morpho.governance_v2.write_last_value_to_file", side_effect=lambda _f, key, value: state.__setitem__(key, str(value)), ), - patch("protocols.morpho.governance_v2.send_alert") as send, + patch("protocols.morpho._alerts.send_alert") as send, ): pc = PendingConfig( valid_at=100, @@ -164,7 +165,7 @@ def _run(self, snapshot: V2GovernanceSnapshot, state: dict[str, Any]) -> list[An side_effect=lambda _f, key: state.get(key, 0), ), patch("protocols.morpho.governance_v2.write_last_value_to_file"), - patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append), + patch("protocols.morpho._alerts.send_alert", side_effect=sent.append), ): governance_v2.diff_and_alert(snapshot) return sent @@ -269,14 +270,14 @@ def test_oversized_group_splits_into_numbered_parts(self) -> None: ] # One section per op: distinct validAt/tx keeps them from collapsing, and # a separate diff category per op is not needed to exceed the cap. - diff = governance_v2._VaultDiff() + diff = governance_v2.VaultDiff() snapshot = _snapshot(pending) for pc in pending: governance_v2._alert_pending_new(snapshot, [(pc, "increaseTimelock(setSendAssetsGate → 604800s)")], diff) sent: list[Any] = [] - with patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append): - governance_v2._send_vault_alerts(snapshot, diff.alerts) + with patch("protocols.morpho._alerts.send_alert", side_effect=sent.append): + governance_v2.send_vault_alerts(governance_v2._vault_header(snapshot), diff.alerts, governance_v2.PROTOCOL) self.assertGreater(len(sent), 1, "oversized group should split into multiple messages") for index, alert in enumerate(sent, start=1): @@ -300,7 +301,7 @@ def test_oversized_pending_section_splits_without_losing_operations(self) -> Non for i in range(30) ] snapshot = _snapshot(pending) - diff = governance_v2._VaultDiff() + diff = governance_v2.VaultDiff() operations: list[tuple[PendingConfig, str]] = [] for i, pc in enumerate(pending): label = f"increaseTimelock(setSendAssetsGate → {604800 + i}s)" @@ -308,15 +309,15 @@ def test_oversized_pending_section_splits_without_losing_operations(self) -> Non governance_v2._alert_pending_new(snapshot, operations, diff) self.assertEqual(len(diff.alerts), 1, "the regression requires one oversized section") - self.assertGreater(len(diff.alerts[0].body), governance_v2.MAX_MESSAGE_LENGTH) + self.assertGreater(len(diff.alerts[0].body), MAX_MESSAGE_LENGTH) sent: list[Any] = [] - with patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append): - governance_v2._send_vault_alerts(snapshot, diff.alerts) + with patch("protocols.morpho._alerts.send_alert", side_effect=sent.append): + governance_v2.send_vault_alerts(governance_v2._vault_header(snapshot), diff.alerts, governance_v2.PROTOCOL) self.assertGreater(len(sent), 1) for alert in sent: - self.assertLessEqual(len(alert.message), governance_v2.MAX_MESSAGE_LENGTH) + self.assertLessEqual(len(alert.message), MAX_MESSAGE_LENGTH) combined = "".join(alert.message for alert in sent) self.assertEqual(combined.count(" • increaseTimelock"), len(pending)) for pc in pending: @@ -343,7 +344,7 @@ def test_cache_writes_are_deferred_until_the_send_succeeds(self) -> None: side_effect=lambda _f, _key: 0, ), patch("protocols.morpho.governance_v2.write_last_value_to_file") as write, - patch("protocols.morpho.governance_v2.send_alert", side_effect=RuntimeError("telegram down")), + patch("protocols.morpho._alerts.send_alert", side_effect=RuntimeError("telegram down")), ): with self.assertRaises(RuntimeError): governance_v2.diff_and_alert(snapshot) @@ -357,7 +358,7 @@ def test_cache_writes_are_deferred_until_the_send_succeeds(self) -> None: side_effect=lambda _f, _key: 0, ), patch("protocols.morpho.governance_v2.write_last_value_to_file") as write, - patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append), + patch("protocols.morpho._alerts.send_alert", side_effect=sent.append), ): governance_v2.diff_and_alert(snapshot)