diff --git a/protocols/morpho/README.md b/protocols/morpho/README.md index e047c4d..ddd3c9f 100644 --- a/protocols/morpho/README.md +++ b/protocols/morpho/README.md @@ -151,6 +151,7 @@ Both scripts share `MORPHO_FILENAME` (default `cache-id.txt`). New key types add | Key suffix | Segment | Value | Meaning | | --- | --- | --- | --- | | `v2_pending` | `keccak(data)` | `validAt` ts, `-1`, or `0` | Pending timelock operation: pending / executed / revoked | +| `v2_pending_function` | `keccak(data)` | function name | Human-readable operation name used when a pending operation later executes or is revoked | | `v2_pending_index` | `pending_keys` | comma-joined `keccak(data)` | Reverse index used to detect operations that disappeared from `pendingConfigs` | | `v2_role` | `owner` / `curator` | lowercase address | Last-known instant-role address | | `v2_set` | `sentinels` / `allocators` / `adapters` | comma-joined lowercase addresses | Last-known role set | diff --git a/protocols/morpho/governance_v2.py b/protocols/morpho/governance_v2.py index 3545bbc..11bc0ea 100644 --- a/protocols/morpho/governance_v2.py +++ b/protocols/morpho/governance_v2.py @@ -47,6 +47,7 @@ # Cache value-type tags used with utils.cache.morpho_key. PENDING_TYPE = "v2_pending" PENDING_INDEX_TYPE = "v2_pending_index" +PENDING_FUNCTION_TYPE = "v2_pending_function" ROLE_TYPE = "v2_role" SET_TYPE = "v2_set" @@ -250,12 +251,30 @@ def _explorer_link(chain: Chain, tx_hash: str) -> str: return f"[{tx_hash[:10]}…]({base}/tx/{tx_hash})" -def _alert_pending_new(snapshot: V2GovernanceSnapshot, pc: PendingConfig) -> None: +def _operation_label(snapshot: V2GovernanceSnapshot, pc: PendingConfig) -> str: decoded = decode_submit(pc.data, snapshot.chain) + if decoded: + return decoded + return pc.function_name or f"`{pc.data_hash[:10]}…`" + + +def _operation_function_name(pc: PendingConfig, operation_label: str) -> str: + if pc.function_name: + return pc.function_name.split("(", 1)[0] + if "(" in operation_label: + return operation_label.split("(", 1)[0] + return "" if operation_label.startswith("<") else operation_label + + +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_telegram_message( f"⏳ V2 [{snapshot.name}]({get_vault_url(snapshot.address, snapshot.chain)}) " f"on {snapshot.chain.name}\n" - f"📥 Submitted: {decoded}\n" + f"📥 Submitted: {operation_label}\n" f"⏰ Executable at: {_format_ts(pc.valid_at)}\n" f"🔗 Tx: {_explorer_link(snapshot.chain, pc.tx_hash)}", PROTOCOL, @@ -266,6 +285,7 @@ def _alert_pending_resolved( snapshot: V2GovernanceSnapshot, data_hash: str, last_valid_at: int, + function_name: str, ) -> None: """Alert that a previously-pending operation no longer appears in pendingConfigs. @@ -276,10 +296,11 @@ def _alert_pending_resolved( now = int(datetime.now().timestamp()) 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_telegram_message( f"{icon} V2 [{snapshot.name}]({get_vault_url(snapshot.address, snapshot.chain)}) " f"on {snapshot.chain.name}\n" - f"Pending operation `{data_hash[:10]}…` was {verb} " + f"Pending operation {operation} was {verb} " f"(was due {_format_ts(last_valid_at)}).", PROTOCOL, ) @@ -325,12 +346,14 @@ def _diff_pending(snapshot: V2GovernanceSnapshot) -> None: 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)) 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) + _alert_pending_new(snapshot, pc, operation_label) _write(cache_key, pc.valid_at) # Detect resolved entries: anything in last-run's index that isn't in the @@ -345,7 +368,7 @@ def _diff_pending(snapshot: V2GovernanceSnapshot) -> None: if last <= 0: # Already marked executed/revoked. continue - _alert_pending_resolved(snapshot, data_hash, last) + _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) _write(index_key, ",".join(sorted(current_keys))) diff --git a/tests/test_morpho_v2_governance.py b/tests/test_morpho_v2_governance.py new file mode 100644 index 0000000..8e0f4ac --- /dev/null +++ b/tests/test_morpho_v2_governance.py @@ -0,0 +1,84 @@ +import unittest +from unittest.mock import patch + +from eth_abi import encode as abi_encode +from web3 import Web3 + +from protocols.morpho import governance_v2 +from protocols.morpho.governance_v2 import PendingConfig, V2GovernanceSnapshot +from protocols.morpho.v2_decoders import submit_data_key +from utils.chains import Chain + +A1 = "0x" + "11" * 20 +VAULT = "0x" + "aa" * 20 + + +def _selector(sig: str) -> bytes: + return bytes(Web3.keccak(text=sig)[:4]) + + +def _build(sig: str, types: list[str], values: list) -> bytes: + return _selector(sig) + abi_encode(types, values) + + +def _snapshot(pending_configs: list[PendingConfig]) -> V2GovernanceSnapshot: + return V2GovernanceSnapshot( + name="Sentora PaypalUSD Main", + address=Web3.to_checksum_address(VAULT), + chain=Chain.MAINNET, + risk_level=3, + owner="", + curator="", + sentinels=[], + allocators=[], + adapters=[], + pending_configs=pending_configs, + ) + + +class TestMorphoV2GovernancePendingLabels(unittest.TestCase): + def test_resolved_pending_alert_uses_cached_function_name(self): + state: dict[str, str] = {} + + def read_value(_filename: str, key: str): + return state.get(key, 0) + + def write_value(_filename: str, key: str, value): + state[key] = str(value) + + data = _build("addAdapter(address)", ["address"], [A1]) + data_hash = submit_data_key(data) + pc = PendingConfig(valid_at=1, function_name="addAdapter", data=data, tx_hash="0x" + "12" * 32) + + 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_telegram_message") as send, + ): + governance_v2._diff_pending(_snapshot([pc])) + send.reset_mock() + + governance_v2._diff_pending(_snapshot([])) + + function_key = governance_v2.morpho_key(VAULT.lower(), data_hash, governance_v2.PENDING_FUNCTION_TYPE) + self.assertEqual(state[function_key], "addAdapter") + + message = send.call_args.args[0] + self.assertIn("Pending operation `addAdapter()` was executed", message) + self.assertNotIn(Web3.to_checksum_address(A1), message) + self.assertNotIn(f"`{data_hash[:10]}…`", message) + self.assertIn("was executed", 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_telegram_message") as send: + governance_v2._alert_pending_resolved(_snapshot([]), data_hash, 1, "") + + message = send.call_args.args[0] + self.assertIn(f"Pending operation `{data_hash[:10]}…` was executed", message) + self.assertNotIn(f"(`{data_hash[:10]}…`)", message) + + +if __name__ == "__main__": + unittest.main()