From b04829e3016fb46f5fefea2c218d4677d6507ffc Mon Sep 17 00:00:00 2001 From: spalen0 <116267321+spalen0@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:51:03 +0000 Subject: [PATCH 1/2] feat(morpho-v2): group per-vault alerts into one Telegram message; sync KATANA V2 vaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes bundled because they unblock the same workflow: a KATANA Gauntlet USDT increaseTimelock landed without a follow-up 'executed' alert because the vault was never in our V2 list, and even for vaults we DO track, a 22-call batch operation (like the same InfiniFi upgrade that surfaced the Wavey Gist bug) would have spammed 22 separate 'pending config' Telegram messages. 1) protocols/morpho/_shared.py: add Gauntlet USDT, Steakhouse High Yield USDC, and Steakhouse Prime USDC to KATANA in VAULTS_V2_BY_CHAIN so they get the same pending/executed/role/set diff treatment as mainnet+BASE. Addresses and risk levels mirror /srv/monitoring prod config (the only place these were already tracked). 2) protocols/morpho/governance_v2.py: per-vault diff categories (pending new, pending resolved, owner, curator, sentinels, allocators, adapters) now buffer into a single list and flush as ONE grouped Telegram message per vault. One header ('V2 [name](url) on chain'), one severity (highest of the group), sections separated by '---'. With the InfiniFi batch as the worst case, this takes a 22-message burst down to 1. Tests: - test_morpho_v2_governance updated for the new buffer-based API (_diff_pending / _alert_* now take an alerts list). - New test_multiple_alerts_for_one_vault_are_grouped_into_single_message asserts 3 pending + 1 owner + 3 set changes produce exactly 1 alert at HIGH severity. - New test_no_alerts_emits_no_message — vaults with no diffs stay silent. - New test_only_low_severity_changes_emit_low_severity_alert — pure allocator diffs are LOW, not the HIGH of the previous 'first-message-wins' style (which had no winner because each message was independent). All 655 tests pass; ruff format/check clean. --- protocols/morpho/_shared.py | 12 ++ protocols/morpho/governance_v2.py | 167 +++++++++++++++++--------- tests/test_morpho_v2_governance.py | 184 ++++++++++++++++++++++++++--- 3 files changed, 292 insertions(+), 71 deletions(-) diff --git a/protocols/morpho/_shared.py b/protocols/morpho/_shared.py index 54515a46..981cb9bc 100644 --- a/protocols/morpho/_shared.py +++ b/protocols/morpho/_shared.py @@ -44,6 +44,18 @@ # ["Yearn OG ETH", "0x5920A6FC553af799542EDA628AdfCc9eA52e141C", 1], ["Yearn KAT", "0x9b1aE9548E4B46cEB6650f6CEc702bAf5CF2b8CC", 1], ["Yearn Degen USDC", "0xA2d38c8A3D810EBcF4C2075821c5eC8F976bb692", 3], + # Synced with /srv/monitoring config (KATANA V2 vaults the team already tracks in prod). + ["Gauntlet USDT", "0xaC596AD9771a8d0D4DF108ae0406e6f913aEdceb", 1], + [ + "Steakhouse High Yield USDC", + "0xbeeff2d5d126d4809195EeA02b605423917bb6c6", + 2, + ], + [ + "Steakhouse Prime USDC", + "0xbeef042bAD4472c3F7Eb9A73070703788b5362D7", + 1, + ], ], } diff --git a/protocols/morpho/governance_v2.py b/protocols/morpho/governance_v2.py index 16934bb0..8b73f7f5 100644 --- a/protocols/morpho/governance_v2.py +++ b/protocols/morpho/governance_v2.py @@ -287,18 +287,65 @@ def _pending_function_key(snapshot: V2GovernanceSnapshot, data_hash: str) -> str return morpho_key(snapshot.address.lower(), data_hash, PENDING_FUNCTION_TYPE) -def _alert_pending_new(snapshot: V2GovernanceSnapshot, pc: PendingConfig, operation_label: str) -> None: - send_alert( - Alert( - AlertSeverity.MEDIUM, - f"âŗ V2 [{snapshot.name}]({get_vault_url(snapshot.address, snapshot.chain)}) " - f"on {snapshot.chain.name}\n" - f"đŸ“Ĩ Submitted: {operation_label}\n" - f"⏰ Executable at: {_format_ts(pc.valid_at)} {_format_countdown(pc.valid_at)}\n" - f"🔗 Tx: {_explorer_link(snapshot.chain, pc.tx_hash)}", - PROTOCOL, - ) +@dataclass +class _VaultAlert: + """One section of a vault's grouped Telegram message. + + Each diff category (``_diff_pending``, ``_diff_single_role``, ``_diff_set``) + appends one of these to a per-vault buffer. ``diff_and_alert`` then combines + every section into a single Telegram message with one header and the highest + severity of the group — so a vault with 3 new pending configs, 1 owner + change, and 1 adapter swap arrives as one alert instead of 5. + """ + + severity: AlertSeverity + body: str + + +# Severity ranking for picking the header severity of a grouped alert. +# Higher number = more severe. CRITICAL > HIGH > MEDIUM > LOW. +_SEVERITY_RANK: dict[AlertSeverity, int] = { + AlertSeverity.CRITICAL: 4, + AlertSeverity.HIGH: 3, + AlertSeverity.MEDIUM: 2, + AlertSeverity.LOW: 1, +} + + +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}" + + +def _send_vault_alerts(snapshot: V2GovernanceSnapshot, alerts: list[_VaultAlert]) -> None: + """Combine every buffered section into a single Telegram message and send it. + + No-op when ``alerts`` is empty so the caller doesn't have to guard. When the + combined body would exceed Telegram's 4096-char limit, fall back to the + ``send_telegram_message`` truncator rather than silently dropping sections; + the team's ``utils.telegram`` will append "..." in that case. + """ + if not alerts: + return + highest = max(alerts, key=lambda a: _SEVERITY_RANK[a.severity]).severity + body = "\n\n---\n\n".join(a.body for a in alerts) + message = f"{_vault_header(snapshot)}\n\n{body}" + send_alert(Alert(highest, message, PROTOCOL)) + + +def _alert_pending_new( + snapshot: V2GovernanceSnapshot, + pc: PendingConfig, + operation_label: str, + alerts: list[_VaultAlert], +) -> None: + """Buffer a new-pending alert body; the caller flushes as one grouped message.""" + body = ( + f"đŸ“Ĩ Submitted: {operation_label}\n" + f"⏰ Executable at: {_format_ts(pc.valid_at)} {_format_countdown(pc.valid_at)}\n" + f"🔗 Tx: {_explorer_link(snapshot.chain, pc.tx_hash)}" ) + alerts.append(_VaultAlert(AlertSeverity.MEDIUM, body)) def _alert_pending_resolved( @@ -306,8 +353,9 @@ def _alert_pending_resolved( data_hash: str, last_valid_at: int, function_name: str, + alerts: list[_VaultAlert], ) -> None: - """Alert that a previously-pending operation no longer appears in pendingConfigs. + """Buffer a resolved-pending alert body. We can't always distinguish ``Accept`` from ``Revoke`` from a snapshot diff, but ``validAt`` gives a strong hint: if it has elapsed, the operation was @@ -317,29 +365,20 @@ def _alert_pending_resolved( verb = "executed" if last_valid_at <= now else "revoked" icon = "✅" if verb == "executed" else "🛑" operation = f"`{function_name}()`" if function_name else f"`{data_hash[:10]}â€Ļ`" - send_alert( - Alert( - AlertSeverity.LOW, - f"{icon} V2 [{snapshot.name}]({get_vault_url(snapshot.address, snapshot.chain)}) " - f"on {snapshot.chain.name}\n" - f"Pending operation {operation} was {verb} " - f"(was due {_format_ts(last_valid_at)}).", - PROTOCOL, - ) - ) + body = f"{icon} Pending operation {operation} was {verb} (was due {_format_ts(last_valid_at)})." + alerts.append(_VaultAlert(AlertSeverity.LOW, body)) -def _alert_role_change(snapshot: V2GovernanceSnapshot, role: str, before: str, after: str) -> None: +def _alert_role_change( + snapshot: V2GovernanceSnapshot, + role: str, + before: str, + after: str, + alerts: list[_VaultAlert], +) -> None: icon = "👑" if role == "owner" else "🎩" - send_alert( - Alert( - AlertSeverity.HIGH, - f"🚨 V2 [{snapshot.name}]({get_vault_url(snapshot.address, snapshot.chain)}) " - f"on {snapshot.chain.name}\n" - f"{icon} {role.capitalize()} changed: `{before}` → `{after}`", - PROTOCOL, - ) - ) + body = f"🚨 {icon} {role.capitalize()} changed: `{before}` → `{after}`" + alerts.append(_VaultAlert(AlertSeverity.HIGH, body)) def _alert_set_diff( @@ -347,6 +386,7 @@ def _alert_set_diff( set_name: str, added: set[str], removed: set[str], + alerts: list[_VaultAlert], ) -> None: icon = {"sentinels": "đŸ›Ąī¸", "allocators": "đŸŽ¯", "adapters": "🧩"}.get(set_name, "â„šī¸") lines: list[str] = [] @@ -354,14 +394,8 @@ def _alert_set_diff( lines.append(f" + `{addr}`") for addr in sorted(removed): lines.append(f" − `{addr}`") - send_alert( - Alert( - AlertSeverity.LOW, - f"{icon} V2 [{snapshot.name}]({get_vault_url(snapshot.address, snapshot.chain)}) " - f"{set_name} changed on {snapshot.chain.name}\n" + "\n".join(lines), - PROTOCOL, - ) - ) + body = f"{icon} {set_name} changed\n" + "\n".join(lines) + alerts.append(_VaultAlert(AlertSeverity.LOW, body)) # ---------------------------------------------------------------------------- @@ -369,7 +403,7 @@ def _alert_set_diff( # ---------------------------------------------------------------------------- -def _diff_pending(snapshot: V2GovernanceSnapshot) -> None: +def _diff_pending(snapshot: V2GovernanceSnapshot, alerts: list[_VaultAlert]) -> None: addr = snapshot.address.lower() current_keys: set[str] = set() @@ -382,7 +416,7 @@ def _diff_pending(snapshot: V2GovernanceSnapshot) -> None: # Already alerted at this validAt, or marked executed. if last == pc.valid_at or last == EXECUTED: continue - _alert_pending_new(snapshot, pc, operation_label) + _alert_pending_new(snapshot, pc, operation_label, alerts) _write(cache_key, pc.valid_at) # Detect resolved entries: anything in last-run's index that isn't in the @@ -397,22 +431,38 @@ def _diff_pending(snapshot: V2GovernanceSnapshot) -> None: if last <= 0: # Already marked executed/revoked. continue - _alert_pending_resolved(snapshot, data_hash, last, _read_str(_pending_function_key(snapshot, data_hash))) + _alert_pending_resolved( + snapshot, + data_hash, + last, + _read_str(_pending_function_key(snapshot, data_hash)), + alerts, + ) _write(cache_key, EXECUTED if last <= int(datetime.now().timestamp()) else REVOKED) _write(index_key, ",".join(sorted(current_keys))) -def _diff_single_role(snapshot: V2GovernanceSnapshot, role: str, current: str) -> None: +def _diff_single_role( + snapshot: V2GovernanceSnapshot, + role: str, + current: str, + alerts: list[_VaultAlert], +) -> 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(snapshot, role, last, current) + _alert_role_change(snapshot, role, last, current, alerts) _write(cache_key, cur_lc) -def _diff_set(snapshot: V2GovernanceSnapshot, set_name: str, current: List[str]) -> None: +def _diff_set( + snapshot: V2GovernanceSnapshot, + set_name: str, + current: List[str], + alerts: list[_VaultAlert], +) -> 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() @@ -423,18 +473,27 @@ def _diff_set(snapshot: V2GovernanceSnapshot, set_name: str, current: List[str]) if last_str and (added or removed): 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(snapshot, set_name, added_cs, removed_cs) + _alert_set_diff(snapshot, set_name, added_cs, removed_cs, alerts) _write(cache_key, ",".join(sorted(current_set))) def diff_and_alert(snapshot: V2GovernanceSnapshot) -> None: - """Diff a vault's snapshot against persisted state and emit Telegram alerts.""" - _diff_pending(snapshot) - _diff_single_role(snapshot, "owner", snapshot.owner) - _diff_single_role(snapshot, "curator", snapshot.curator) - _diff_set(snapshot, "sentinels", snapshot.sentinels) - _diff_set(snapshot, "allocators", snapshot.allocators) - _diff_set(snapshot, "adapters", snapshot.adapters) + """Diff a vault's snapshot against persisted state and emit one grouped alert. + + Every diff category (pending, owner/curator, sentinels/allocators/adapters) + appends to a per-vault buffer. We then send a single Telegram message with + one header (``V2 [name](url) on chain``) and the highest severity of the + group, so a vault with several simultaneous changes doesn't spam N + separate messages. + """ + alerts: list[_VaultAlert] = [] + _diff_pending(snapshot, alerts) + _diff_single_role(snapshot, "owner", snapshot.owner, alerts) + _diff_single_role(snapshot, "curator", snapshot.curator, alerts) + _diff_set(snapshot, "sentinels", snapshot.sentinels, alerts) + _diff_set(snapshot, "allocators", snapshot.allocators, alerts) + _diff_set(snapshot, "adapters", snapshot.adapters, alerts) + _send_vault_alerts(snapshot, alerts) # ---------------------------------------------------------------------------- diff --git a/tests/test_morpho_v2_governance.py b/tests/test_morpho_v2_governance.py index 47151963..9d590fc7 100644 --- a/tests/test_morpho_v2_governance.py +++ b/tests/test_morpho_v2_governance.py @@ -50,34 +50,184 @@ def write_value(_filename: str, key: str, value): data_hash = submit_data_key(data) pc = PendingConfig(valid_at=1, function_name="addAdapter", data=data, tx_hash="0x" + "12" * 32) + sent_calls: list = [] + + def capture(alert): + sent_calls.append(alert) + 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.governance_v2.send_alert", side_effect=capture), ): - governance_v2._diff_pending(_snapshot([pc])) - send.reset_mock() - - governance_v2._diff_pending(_snapshot([])) + # First call: pending config appears, buffered, then flushed as one alert. + alerts: list = [] + governance_v2._diff_pending(_snapshot([pc]), alerts) + governance_v2._send_vault_alerts(_snapshot([pc]), alerts) + # Second call: the cached pending op is no longer present, so the + # resolved-pending branch fires. + alerts2: list = [] + governance_v2._diff_pending(_snapshot([]), alerts2) + governance_v2._send_vault_alerts(_snapshot([]), alerts2) function_key = governance_v2.morpho_key(VAULT.lower(), data_hash, governance_v2.PENDING_FUNCTION_TYPE) self.assertEqual(state[function_key], "addAdapter") - alert = send.call_args.args[0] - self.assertIn("Pending operation `addAdapter()` was executed", alert.message) - self.assertNotIn(Web3.to_checksum_address(A1), alert.message) - self.assertNotIn(f"`{data_hash[:10]}â€Ļ`", alert.message) - self.assertIn("was executed", alert.message) + self.assertEqual(len(sent_calls), 2) + # The second alert (resolved) is the one we assert on — it's the one with + # "was executed". The first one is the original Submit. + resolved_alert = sent_calls[1] + self.assertIn("Pending operation `addAdapter()` was executed", resolved_alert.message) + self.assertNotIn(Web3.to_checksum_address(A1), resolved_alert.message) + self.assertNotIn(f"`{data_hash[:10]}â€Ļ`", resolved_alert.message) + self.assertIn("was executed", resolved_alert.message) def test_resolved_pending_alert_without_cached_function_keeps_hash_only_message(self): data_hash = "3d6d72861e" + "0" * 54 - - with patch("protocols.morpho.governance_v2.send_alert") as send: - governance_v2._alert_pending_resolved(_snapshot([]), data_hash, 1, "") - - alert = send.call_args.args[0] - self.assertIn(f"Pending operation `{data_hash[:10]}â€Ļ` was executed", alert.message) - self.assertNotIn(f"(`{data_hash[:10]}â€Ļ`)", alert.message) + snapshot = _snapshot([]) + alerts: list = [] + governance_v2._alert_pending_resolved(snapshot, data_hash, 1, "", alerts) + # The buffered body is what the previous test checked, but now the alert + # is built into a grouped message — flush it and inspect the body. + sent: list = [] + with patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append): + governance_v2._send_vault_alerts(snapshot, alerts) + self.assertEqual(len(sent), 1) + message = sent[0].message + self.assertIn(f"Pending operation `{data_hash[:10]}â€Ļ` was executed", message) + self.assertNotIn(f"(`{data_hash[:10]}â€Ļ`)", message) + + def test_multiple_alerts_for_one_vault_are_grouped_into_single_message(self): + """A vault with several simultaneous changes should fire ONE Telegram message. + + 3 new pending submits + 1 owner change + 1 adapter swap = 1 alert with + 5 sections under one header. Verifies the grouping refactor. + """ + snapshot = V2GovernanceSnapshot( + name="Test Vault", + address=Web3.to_checksum_address(VAULT), + chain=Chain.MAINNET, + risk_level=1, + owner="0x" + "bb" * 20, # current owner + curator="0x" + "cc" * 20, + sentinels=[], + allocators=[], + adapters=["0x" + "dd" * 20], # current adapter + pending_configs=[ + PendingConfig(valid_at=100, function_name="addAdapter", data=b"\x01" * 4, tx_hash="0x" + "11" * 32), + PendingConfig(valid_at=200, function_name="addAdapter", data=b"\x02" * 4, tx_hash="0x" + "22" * 32), + PendingConfig(valid_at=300, function_name="addAdapter", data=b"\x03" * 4, tx_hash="0x" + "33" * 32), + ], + ) + + sent: list = [] + # Seed every cache key with a non-empty "before" state so the diff fires + # for owner, sentinels, allocators, and adapters (not just the pending ones). + old_adapter = "0x" + "ee" * 20 + state: dict = { + governance_v2.morpho_key(VAULT.lower(), "owner", "v2_role"): "0x" + "ff" * 20, + governance_v2.morpho_key(VAULT.lower(), "curator", "v2_role"): (snapshot.curator or "").lower(), + governance_v2.morpho_key(VAULT.lower(), "sentinels", "v2_set"): old_adapter.lower(), + governance_v2.morpho_key(VAULT.lower(), "allocators", "v2_set"): old_adapter.lower(), + governance_v2.morpho_key(VAULT.lower(), "adapters", "v2_set"): old_adapter.lower(), + } + with ( + patch( + "protocols.morpho.governance_v2.get_last_value_for_key_from_file", + side_effect=lambda _f, k: state.get(k, 0), + ), + patch("protocols.morpho.governance_v2.write_last_value_to_file"), + patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append), + ): + governance_v2.diff_and_alert(snapshot) + + # Exactly ONE Telegram message should have been sent (the grouped alert). + self.assertEqual(len(sent), 1, f"expected 1 grouped alert, got {len(sent)}") + alert = sent[0] + # Highest severity wins: 1 owner change (HIGH) > 3 pending (MEDIUM) > 1 set (LOW). + self.assertEqual(alert.severity, governance_v2.AlertSeverity.HIGH) + # Header once + 3 submitted sections + 1 owner change + 3 set changes. + message = alert.message + self.assertIn("V2 [Test Vault]", message) + self.assertIn("đŸ“Ĩ Submitted", message) + self.assertEqual(message.count("đŸ“Ĩ Submitted:"), 3) + self.assertIn("Owner changed", message) + self.assertIn("sentinels changed", message) + self.assertIn("allocators changed", message) + self.assertIn("adapters changed", message) + # All three pending Txs are present. + for tx in ("0x" + "11" * 32, "0x" + "22" * 32, "0x" + "33" * 32): + self.assertIn(tx, message) + + def test_no_alerts_emits_no_message(self): + """Vault with no diffs should not produce a Telegram message at all.""" + snapshot = V2GovernanceSnapshot( + name="Quiet Vault", + address=Web3.to_checksum_address(VAULT), + chain=Chain.MAINNET, + risk_level=1, + owner="0x" + "ff" * 20, + curator="0x" + "ff" * 20, + sentinels=[], + allocators=[], + adapters=[], + pending_configs=[], + ) + sent: list = [] + # Seed every cache key the diff functions consult, so no diffs fire. + state: dict = { + governance_v2.morpho_key(VAULT.lower(), "owner", "v2_role"): (snapshot.owner or "").lower(), + governance_v2.morpho_key(VAULT.lower(), "curator", "v2_role"): (snapshot.curator or "").lower(), + governance_v2.morpho_key(VAULT.lower(), "sentinels", "v2_set"): "", + governance_v2.morpho_key(VAULT.lower(), "allocators", "v2_set"): "", + governance_v2.morpho_key(VAULT.lower(), "adapters", "v2_set"): "", + } + with ( + patch( + "protocols.morpho.governance_v2.get_last_value_for_key_from_file", + side_effect=lambda _f, k: state.get(k, 0), + ), + patch("protocols.morpho.governance_v2.write_last_value_to_file"), + patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append), + ): + governance_v2.diff_and_alert(snapshot) + self.assertEqual(sent, []) + + def test_only_low_severity_changes_emit_low_severity_alert(self): + """Pure allocator/pending-resolved diffs → LOW, not the high of HIGH/CRITICAL fallback.""" + snapshot = V2GovernanceSnapshot( + name="Low Vault", + address=Web3.to_checksum_address(VAULT), + chain=Chain.MAINNET, + risk_level=1, + owner="0x" + "ff" * 20, + curator="0x" + "ff" * 20, + sentinels=[], + allocators=["0x" + "aa" * 20], # newly added + adapters=[], + pending_configs=[], + ) + sent: list = [] + # Seed the allocators set with a non-empty "before" so the diff actually fires + # (first-run cache seeding is silent by design). + state: dict = { + governance_v2.morpho_key(VAULT.lower(), "owner", "v2_role"): (snapshot.owner or "").lower(), + governance_v2.morpho_key(VAULT.lower(), "curator", "v2_role"): (snapshot.curator or "").lower(), + governance_v2.morpho_key(VAULT.lower(), "sentinels", "v2_set"): "", + governance_v2.morpho_key(VAULT.lower(), "allocators", "v2_set"): "0x" + "ee" * 20, + governance_v2.morpho_key(VAULT.lower(), "adapters", "v2_set"): "", + } + with ( + patch( + "protocols.morpho.governance_v2.get_last_value_for_key_from_file", + side_effect=lambda _f, k: state.get(k, 0), + ), + patch("protocols.morpho.governance_v2.write_last_value_to_file"), + patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append), + ): + governance_v2.diff_and_alert(snapshot) + self.assertEqual(len(sent), 1) + self.assertEqual(sent[0].severity, governance_v2.AlertSeverity.LOW) if __name__ == "__main__": From 6a8acde7f05b09f9cb01b002f6848e935c0f504f Mon Sep 17 00:00:00 2001 From: spalen0 Date: Fri, 31 Jul 2026 09:52:43 +0000 Subject: [PATCH 2/2] fix(morpho-v2): split oversized grouped alerts, commit cache after send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the per-vault alert grouping: * Split a grouped alert into "(i/N)" parts instead of letting utils.telegram truncate it. The 22-call batch that motivated the grouping renders ~6.3k chars, so the previous single message lost 7 of 22 sections and dropped Markdown; it now sends as 2 parts with every section intact. * Buffer cache writes in _VaultDiff and commit them only after the send succeeds. Writing during the diff pass marked changes as alerted even when delivery failed — main() logs and moves on, so the alert was lost for good. * Replace the _SEVERITY_RANK dict with an ascending _SEVERITY_ORDER tuple, and drop the snapshot argument from the _alert_* helpers that no longer use it (the header moved out of the section bodies). * Correct the "Yearn-curated" docstrings: VAULTS_V2_BY_CHAIN also holds Gauntlet/Steakhouse vaults, and a row there enrols the vault in markets_v2 checks as well as governance diffs. Co-Authored-By: Claude Opus 5 --- protocols/morpho/_shared.py | 27 ++-- protocols/morpho/governance_v2.py | 201 +++++++++++++++++------------ protocols/morpho/markets_v2.py | 4 +- tests/test_morpho_v2_governance.py | 100 ++++++++++++-- 4 files changed, 217 insertions(+), 115 deletions(-) diff --git a/protocols/morpho/_shared.py b/protocols/morpho/_shared.py index 981cb9bc..a9aaf880 100644 --- a/protocols/morpho/_shared.py +++ b/protocols/morpho/_shared.py @@ -13,12 +13,14 @@ logger = get_logger("morpho.shared") -# Yearn-curated Morpho V2 vaults — sourced from +# Morpho V2 vaults we monitor. Mostly Yearn-curated — sourced from # https://app.morpho.org/curator/yearn?v2=true (filtered via GraphQL by -# Yearn's curator addresses). Imported by both ``markets_v2.py`` and -# ``governance_v2.py``. To add a new vault, append a -# ``[name, address, risk_level]`` row to the appropriate chain. Risk levels -# follow the same 1–5 scheme as v1 ``markets.py:VAULTS_BY_CHAIN``. +# Yearn's curator addresses) — plus a few third-party vaults (Gauntlet, +# Steakhouse) the team tracks because of Yearn's exposure to them. +# Imported by both ``markets_v2.py`` and ``governance_v2.py``, so adding a row +# here enrols the vault in market checks *and* governance diffs. To add a new +# vault, append a ``[name, address, risk_level]`` row to the appropriate chain. +# Risk levels follow the same 1–5 scheme as v1 ``markets.py:VAULTS_BY_CHAIN``. VAULTS_V2_BY_CHAIN: Dict[Chain, List[List[Any]]] = { Chain.MAINNET: [ # name, address, risk level @@ -44,18 +46,11 @@ # ["Yearn OG ETH", "0x5920A6FC553af799542EDA628AdfCc9eA52e141C", 1], ["Yearn KAT", "0x9b1aE9548E4B46cEB6650f6CEc702bAf5CF2b8CC", 1], ["Yearn Degen USDC", "0xA2d38c8A3D810EBcF4C2075821c5eC8F976bb692", 3], - # Synced with /srv/monitoring config (KATANA V2 vaults the team already tracks in prod). + # Synced with /srv/monitoring config (KATANA V2 vaults the team already + # tracks in prod). Curated by Gauntlet and Steakhouse, not Yearn. ["Gauntlet USDT", "0xaC596AD9771a8d0D4DF108ae0406e6f913aEdceb", 1], - [ - "Steakhouse High Yield USDC", - "0xbeeff2d5d126d4809195EeA02b605423917bb6c6", - 2, - ], - [ - "Steakhouse Prime USDC", - "0xbeef042bAD4472c3F7Eb9A73070703788b5362D7", - 1, - ], + ["Steakhouse High Yield USDC", "0xbeeff2d5d126d4809195EeA02b605423917bb6c6", 2], + ["Steakhouse Prime USDC", "0xbeef042bAD4472c3F7Eb9A73070703788b5362D7", 1], ], } diff --git a/protocols/morpho/governance_v2.py b/protocols/morpho/governance_v2.py index 8b73f7f5..b825fe1a 100644 --- a/protocols/morpho/governance_v2.py +++ b/protocols/morpho/governance_v2.py @@ -289,27 +289,53 @@ def _pending_function_key(snapshot: V2GovernanceSnapshot, data_hash: str) -> str @dataclass class _VaultAlert: - """One section of a vault's grouped Telegram message. + """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 one of these to a per-vault buffer. ``diff_and_alert`` then combines - every section into a single Telegram message with one header and the highest - severity of the group — so a vault with 3 new pending configs, 1 owner - change, and 1 adapter swap arrives as one alert instead of 5. + appends its sections here instead of sending immediately, so a vault with 3 + new pending configs, 1 owner change, and 1 adapter swap arrives as one alert + instead of 5. + + Cache writes are buffered too, and committed only once the Telegram send has + succeeded (see ``diff_and_alert``). Writing them during the diff pass would + mark changes as alerted even when delivery failed — ``main`` swallows the + exception, so the alert would be lost for good. """ - severity: AlertSeverity - body: str + 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)) -# Severity ranking for picking the header severity of a grouped alert. -# Higher number = more severe. CRITICAL > HIGH > MEDIUM > LOW. -_SEVERITY_RANK: dict[AlertSeverity, int] = { - AlertSeverity.CRITICAL: 4, - AlertSeverity.HIGH: 3, - AlertSeverity.MEDIUM: 2, - AlertSeverity.LOW: 1, -} + def write(self, key: str, value: Any) -> None: + """Buffer a cache write to apply after 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 order — a grouped alert takes the highest of its sections. +_SEVERITY_ORDER = (AlertSeverity.LOW, AlertSeverity.MEDIUM, AlertSeverity.HIGH, AlertSeverity.CRITICAL) + +# Telegram caps a message at 4096 chars, and ``utils.telegram`` truncates +# anything longer (dropping Markdown with it), so a large batch would silently +# lose its tail. We split into "(i/N)" parts instead. The budget leaves headroom +# for the header line, the part suffix, and the emoji ``send_alert`` prepends. +_MAX_MESSAGE_CHARS = 3900 +_SECTION_SEPARATOR = "\n\n---\n\n" def _vault_header(snapshot: V2GovernanceSnapshot) -> str: @@ -317,43 +343,64 @@ def _vault_header(snapshot: V2GovernanceSnapshot) -> str: return f"V2 [{snapshot.name}]({get_vault_url(snapshot.address, snapshot.chain)}) on {snapshot.chain.name}" -def _send_vault_alerts(snapshot: V2GovernanceSnapshot, alerts: list[_VaultAlert]) -> None: - """Combine every buffered section into a single Telegram message and send it. +def _split_into_messages(alerts: List[_VaultAlert], budget: int) -> List[List[str]]: + """Pack section bodies into groups that each fit within ``budget`` chars. - No-op when ``alerts`` is empty so the caller doesn't have to guard. When the - combined body would exceed Telegram's 4096-char limit, fall back to the - ``send_telegram_message`` truncator rather than silently dropping sections; - the team's ``utils.telegram`` will append "..." in that case. + A single section bigger than the budget still gets its own message rather + than pushing its neighbours out: Telegram truncates that one section, but no + other section is lost. + """ + parts: List[List[str]] = [[]] + size = 0 + for alert in alerts: + cost = len(alert.body) + len(_SECTION_SEPARATOR) + if parts[-1] and size + cost > budget: + parts.append([]) + size = 0 + parts[-1].append(alert.body) + size += cost + 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 the caller doesn'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 - highest = max(alerts, key=lambda a: _SEVERITY_RANK[a.severity]).severity - body = "\n\n---\n\n".join(a.body for a in alerts) - message = f"{_vault_header(snapshot)}\n\n{body}" - send_alert(Alert(highest, message, PROTOCOL)) + severity = max((a.severity for a in alerts), key=_SEVERITY_ORDER.index) + header = _vault_header(snapshot) + parts = _split_into_messages(alerts, _MAX_MESSAGE_CHARS - 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, pc: PendingConfig, operation_label: str, - alerts: list[_VaultAlert], + diff: _VaultDiff, ) -> None: """Buffer a new-pending alert body; the caller flushes as one grouped message.""" - body = ( + diff.alert( + AlertSeverity.MEDIUM, f"đŸ“Ĩ Submitted: {operation_label}\n" f"⏰ Executable at: {_format_ts(pc.valid_at)} {_format_countdown(pc.valid_at)}\n" - f"🔗 Tx: {_explorer_link(snapshot.chain, pc.tx_hash)}" + f"🔗 Tx: {_explorer_link(snapshot.chain, pc.tx_hash)}", ) - alerts.append(_VaultAlert(AlertSeverity.MEDIUM, body)) def _alert_pending_resolved( - snapshot: V2GovernanceSnapshot, data_hash: str, last_valid_at: int, function_name: str, - alerts: list[_VaultAlert], + diff: _VaultDiff, ) -> None: """Buffer a resolved-pending alert body. @@ -365,37 +412,24 @@ def _alert_pending_resolved( verb = "executed" if last_valid_at <= now else "revoked" icon = "✅" if verb == "executed" else "🛑" operation = f"`{function_name}()`" if function_name else f"`{data_hash[:10]}â€Ļ`" - body = f"{icon} Pending operation {operation} was {verb} (was due {_format_ts(last_valid_at)})." - alerts.append(_VaultAlert(AlertSeverity.LOW, body)) + diff.alert( + AlertSeverity.LOW, f"{icon} Pending operation {operation} was {verb} (was due {_format_ts(last_valid_at)})." + ) -def _alert_role_change( - snapshot: V2GovernanceSnapshot, - role: str, - before: str, - after: str, - alerts: list[_VaultAlert], -) -> None: +def _alert_role_change(role: str, before: str, after: str, diff: _VaultDiff) -> None: icon = "👑" if role == "owner" else "🎩" - body = f"🚨 {icon} {role.capitalize()} changed: `{before}` → `{after}`" - alerts.append(_VaultAlert(AlertSeverity.HIGH, body)) + diff.alert(AlertSeverity.HIGH, f"🚨 {icon} {role.capitalize()} changed: `{before}` → `{after}`") -def _alert_set_diff( - snapshot: V2GovernanceSnapshot, - set_name: str, - added: set[str], - removed: set[str], - alerts: list[_VaultAlert], -) -> 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): lines.append(f" + `{addr}`") for addr in sorted(removed): lines.append(f" − `{addr}`") - body = f"{icon} {set_name} changed\n" + "\n".join(lines) - alerts.append(_VaultAlert(AlertSeverity.LOW, body)) + diff.alert(AlertSeverity.LOW, f"{icon} {set_name} changed\n" + "\n".join(lines)) # ---------------------------------------------------------------------------- @@ -403,21 +437,21 @@ def _alert_set_diff( # ---------------------------------------------------------------------------- -def _diff_pending(snapshot: V2GovernanceSnapshot, alerts: list[_VaultAlert]) -> None: +def _diff_pending(snapshot: V2GovernanceSnapshot, diff: _VaultDiff) -> None: addr = snapshot.address.lower() current_keys: set[str] = set() for pc in snapshot.pending_configs: current_keys.add(pc.data_hash) operation_label = _operation_label(snapshot, pc) - _write(_pending_function_key(snapshot, pc.data_hash), _operation_function_name(pc, operation_label)) + diff.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 - _alert_pending_new(snapshot, pc, operation_label, alerts) - _write(cache_key, pc.valid_at) + _alert_pending_new(snapshot, pc, operation_label, diff) + diff.write(cache_key, pc.valid_at) # Detect resolved entries: anything in last-run's index that isn't in the # current pending list. @@ -432,37 +466,26 @@ def _diff_pending(snapshot: V2GovernanceSnapshot, alerts: list[_VaultAlert]) -> # Already marked executed/revoked. continue _alert_pending_resolved( - snapshot, data_hash, last, _read_str(_pending_function_key(snapshot, data_hash)), - alerts, + diff, ) - _write(cache_key, EXECUTED if last <= int(datetime.now().timestamp()) else REVOKED) + diff.write(cache_key, EXECUTED if last <= int(datetime.now().timestamp()) else REVOKED) - _write(index_key, ",".join(sorted(current_keys))) + diff.write(index_key, ",".join(sorted(current_keys))) -def _diff_single_role( - snapshot: V2GovernanceSnapshot, - role: str, - current: str, - alerts: list[_VaultAlert], -) -> 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(snapshot, role, last, current, alerts) - _write(cache_key, cur_lc) + _alert_role_change(role, last, current, diff) + diff.write(cache_key, cur_lc) -def _diff_set( - snapshot: V2GovernanceSnapshot, - set_name: str, - current: List[str], - alerts: list[_VaultAlert], -) -> 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() @@ -473,8 +496,8 @@ def _diff_set( if last_str and (added or removed): 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(snapshot, set_name, added_cs, removed_cs, alerts) - _write(cache_key, ",".join(sorted(current_set))) + _alert_set_diff(set_name, added_cs, removed_cs, diff) + diff.write(cache_key, ",".join(sorted(current_set))) def diff_and_alert(snapshot: V2GovernanceSnapshot) -> None: @@ -483,17 +506,25 @@ def diff_and_alert(snapshot: V2GovernanceSnapshot) -> None: Every diff category (pending, owner/curator, sentinels/allocators/adapters) appends to a per-vault buffer. We then send a single Telegram message with one header (``V2 [name](url) on chain``) and the highest severity of the - group, so a vault with several simultaneous changes doesn't spam N - separate messages. + group, so a vault with several simultaneous changes doesn't spam N separate + messages; only a group too long for one Telegram message is split into + numbered parts. + + Cache cursors are committed after the send, not during the diff: if Telegram + is down, the next run re-detects the same changes and re-alerts rather than + treating them as already delivered. A partial send (part 1 of 3 lands, part 2 + fails) therefore repeats the whole group next run — duplicates beat a + governance change nobody ever sees. """ - alerts: list[_VaultAlert] = [] - _diff_pending(snapshot, alerts) - _diff_single_role(snapshot, "owner", snapshot.owner, alerts) - _diff_single_role(snapshot, "curator", snapshot.curator, alerts) - _diff_set(snapshot, "sentinels", snapshot.sentinels, alerts) - _diff_set(snapshot, "allocators", snapshot.allocators, alerts) - _diff_set(snapshot, "adapters", snapshot.adapters, alerts) - _send_vault_alerts(snapshot, alerts) + 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) + diff.commit() # ---------------------------------------------------------------------------- diff --git a/protocols/morpho/markets_v2.py b/protocols/morpho/markets_v2.py index e73dc876..3f011f7d 100644 --- a/protocols/morpho/markets_v2.py +++ b/protocols/morpho/markets_v2.py @@ -1,6 +1,6 @@ """Morpho VaultV2 markets / allocation / risk monitor. -Iterates the explicit ``VAULTS_V2_BY_CHAIN`` list (Yearn-curated V2 vaults), then +Iterates the explicit ``VAULTS_V2_BY_CHAIN`` list (the V2 vaults we monitor), then for each vault reads its adapters on-chain and: * For ``MorphoVaultV1Adapter`` (V2 wraps a v1 MetaMorpho vault) — sanity-checks @@ -76,7 +76,7 @@ @dataclass class V2Vault: - """Yearn-curated V2 vault declared in ``VAULTS_V2_BY_CHAIN``.""" + """Monitored V2 vault declared in ``VAULTS_V2_BY_CHAIN``.""" name: str address: str diff --git a/tests/test_morpho_v2_governance.py b/tests/test_morpho_v2_governance.py index 9d590fc7..5e0fff4c 100644 --- a/tests/test_morpho_v2_governance.py +++ b/tests/test_morpho_v2_governance.py @@ -60,15 +60,10 @@ def capture(alert): patch("protocols.morpho.governance_v2.write_last_value_to_file", side_effect=write_value), patch("protocols.morpho.governance_v2.send_alert", side_effect=capture), ): - # First call: pending config appears, buffered, then flushed as one alert. - alerts: list = [] - governance_v2._diff_pending(_snapshot([pc]), alerts) - governance_v2._send_vault_alerts(_snapshot([pc]), alerts) - # Second call: the cached pending op is no longer present, so the - # resolved-pending branch fires. - alerts2: list = [] - governance_v2._diff_pending(_snapshot([]), alerts2) - governance_v2._send_vault_alerts(_snapshot([]), alerts2) + # First run: the pending config appears and is alerted as a Submit. + governance_v2.diff_and_alert(_snapshot([pc])) + # Second run: the cached pending op is gone, so the resolved branch fires. + governance_v2.diff_and_alert(_snapshot([])) function_key = governance_v2.morpho_key(VAULT.lower(), data_hash, governance_v2.PENDING_FUNCTION_TYPE) self.assertEqual(state[function_key], "addAdapter") @@ -85,13 +80,13 @@ def capture(alert): def test_resolved_pending_alert_without_cached_function_keeps_hash_only_message(self): data_hash = "3d6d72861e" + "0" * 54 snapshot = _snapshot([]) - alerts: list = [] - governance_v2._alert_pending_resolved(snapshot, data_hash, 1, "", alerts) + diff = governance_v2._VaultDiff() + governance_v2._alert_pending_resolved(data_hash, 1, "", diff) # The buffered body is what the previous test checked, but now the alert # is built into a grouped message — flush it and inspect the body. sent: list = [] with patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append): - governance_v2._send_vault_alerts(snapshot, alerts) + governance_v2._send_vault_alerts(snapshot, diff.alerts) self.assertEqual(len(sent), 1) message = sent[0].message self.assertIn(f"Pending operation `{data_hash[:10]}â€Ļ` was executed", message) @@ -229,6 +224,87 @@ def test_only_low_severity_changes_emit_low_severity_alert(self): self.assertEqual(len(sent), 1) self.assertEqual(sent[0].severity, governance_v2.AlertSeverity.LOW) + def test_oversized_group_is_split_into_numbered_parts_without_dropping_sections(self): + """A batch too long for one Telegram message splits instead of truncating. + + The 22-call InfiniFi-style batch renders ~6.3k chars — past Telegram's + 4096 cap, where ``utils.telegram`` would truncate and silently drop the + tail. Every section must survive across the numbered parts. + """ + pending = [ + PendingConfig( + valid_at=1800000000, + function_name="increaseTimelock", + data=bytes([i]) * 4, + tx_hash="0x" + f"{i:02x}" * 32, + ) + for i in range(22) + ] + snapshot = _snapshot(pending) + + sent: list = [] + with ( + patch( + "protocols.morpho.governance_v2.get_last_value_for_key_from_file", + side_effect=lambda _f, _k: 0, + ), + patch("protocols.morpho.governance_v2.write_last_value_to_file"), + patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append), + ): + governance_v2.diff_and_alert(snapshot) + + self.assertGreater(len(sent), 1, "oversized group should split into multiple messages") + for index, alert in enumerate(sent, start=1): + self.assertLessEqual(len(alert.message), 4096) + self.assertIn(f"V2 [{snapshot.name}]", alert.message) + self.assertIn(f"({index}/{len(sent)})", alert.message) + combined = "".join(a.message for a in sent) + self.assertEqual(combined.count("đŸ“Ĩ Submitted:"), 22) + for pc in pending: + self.assertIn(pc.tx_hash, combined) + + def test_cache_writes_are_deferred_until_the_send_succeeds(self): + """A failed Telegram send must leave the cache untouched so the next run retries. + + Writing cursors during the diff pass would mark the change as alerted even + though nothing was delivered — ``main`` logs the exception and moves on, so + the alert would be lost permanently. + """ + pc = PendingConfig( + valid_at=1800000000, + function_name="addAdapter", + data=_build("addAdapter(address)", ["address"], [A1]), + tx_hash="0x" + "12" * 32, + ) + snapshot = _snapshot([pc]) + + with ( + patch( + "protocols.morpho.governance_v2.get_last_value_for_key_from_file", + side_effect=lambda _f, _k: 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")), + ): + with self.assertRaises(RuntimeError): + governance_v2.diff_and_alert(snapshot) + write.assert_not_called() + + # Same snapshot, working Telegram: cursors are committed this time. + sent: list = [] + with ( + patch( + "protocols.morpho.governance_v2.get_last_value_for_key_from_file", + side_effect=lambda _f, _k: 0, + ), + patch("protocols.morpho.governance_v2.write_last_value_to_file") as write, + patch("protocols.morpho.governance_v2.send_alert", side_effect=sent.append), + ): + governance_v2.diff_and_alert(snapshot) + self.assertEqual(len(sent), 1) + written_keys = {call.args[1] for call in write.call_args_list} + self.assertIn(governance_v2.morpho_key(VAULT.lower(), pc.data_hash, governance_v2.PENDING_TYPE), written_keys) + if __name__ == "__main__": unittest.main()