From bd16512c9d53ec03a9421f43848ce7ae07576cdf Mon Sep 17 00:00:00 2001 From: spalen0 Date: Fri, 7 Aug 2026 10:34:54 +0200 Subject: [PATCH] feat(morpho): group V1 governance alerts per vault; denominate V2 caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the V2 per-vault grouping to the V1 governance monitor and moves the shared machinery into protocols/morpho/_alerts.py, so both monitors buffer findings into one VaultDiff and flush a single Telegram message per vault. A vault queueing two new markets in the same multicall used to send two near-identical messages that repeated the vault name and chain in each; it now sends one with a header naming the vault once and a section per market. Section bodies drop the "to vault X on CHAIN" suffix the header carries. Two fixes the shared module picks up: * A single section larger than one Telegram message (a batched multicall submit renders one bullet per operation) now splits on line boundaries instead of being handed to Telegram whole and truncated. * V1 cache cursors are deferred like V2's, so a failed send is retried on the next run rather than being recorded as alerted. Absolute-cap decoding: every V2 cap limits the vault's own allocation, so it is denominated in the vault's asset whatever id it is keyed by. The collateralToken path had no asset to hand and printed the raw integer — "cap 25000000000000" for what is 25.00M USDC. governance_v2 now reads asset { symbol decimals } in the query it already issues and threads it into decode_submit. type(uint128).max renders as "unlimited" rather than a 340-undecillion figure. Co-Authored-By: Claude Opus 5 (1M context) --- protocols/morpho/README.md | 6 +- protocols/morpho/_alerts.py | 123 ++++++++++++++++++++++++ protocols/morpho/governance.py | 88 +++++++++-------- protocols/morpho/governance_v2.py | 148 +++++++---------------------- protocols/morpho/v2_decoders.py | 61 ++++++++++-- tests/test_morpho_governance.py | 94 +++++++++++++----- tests/test_morpho_v2_decoders.py | 4 +- tests/test_morpho_v2_governance.py | 35 +++---- 8 files changed, 356 insertions(+), 203 deletions(-) create mode 100644 protocols/morpho/_alerts.py 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)