diff --git a/protocols/morpho/_shared.py b/protocols/morpho/_shared.py index 54515a46..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,6 +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). Curated by Gauntlet and Steakhouse, not Yearn. + ["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..b825fe1a 100644 --- a/protocols/morpho/governance_v2.py +++ b/protocols/morpho/governance_v2.py @@ -287,27 +287,122 @@ 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.""" + + 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 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. + """ + + 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 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: + """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 _split_into_messages(alerts: List[_VaultAlert], budget: int) -> List[List[str]]: + """Pack section bodies into groups that each fit within ``budget`` chars. + + 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 + 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, + diff: _VaultDiff, +) -> None: + """Buffer a new-pending alert body; the caller flushes as one grouped message.""" + 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)}", ) def _alert_pending_resolved( - snapshot: V2GovernanceSnapshot, data_hash: str, last_valid_at: int, function_name: str, + diff: _VaultDiff, ) -> 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,51 +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]}…`" - 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, - ) + 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) -> None: +def _alert_role_change(role: str, before: str, after: str, diff: _VaultDiff) -> 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, - ) - ) + 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], -) -> 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}`") - 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, - ) - ) + diff.alert(AlertSeverity.LOW, f"{icon} {set_name} changed\n" + "\n".join(lines)) # ---------------------------------------------------------------------------- @@ -369,21 +437,21 @@ def _alert_set_diff( # ---------------------------------------------------------------------------- -def _diff_pending(snapshot: V2GovernanceSnapshot) -> 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) - _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. @@ -397,22 +465,27 @@ 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))) - _write(cache_key, EXECUTED if last <= int(datetime.now().timestamp()) else REVOKED) + _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) - _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) -> 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) - _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]) -> 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() @@ -423,18 +496,35 @@ 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) - _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: - """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; 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. + """ + 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 47151963..5e0fff4c 100644 --- a/tests/test_morpho_v2_governance.py +++ b/tests/test_morpho_v2_governance.py @@ -50,34 +50,260 @@ 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 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") - 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 + snapshot = _snapshot([]) + 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, diff.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) - with patch("protocols.morpho.governance_v2.send_alert") as send: - governance_v2._alert_pending_resolved(_snapshot([]), data_hash, 1, "") + 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) - 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) + 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__":