Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions protocols/morpho/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
33 changes: 28 additions & 5 deletions protocols/morpho/governance_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand All @@ -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.

Expand All @@ -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,
)
Expand Down Expand Up @@ -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
Expand All @@ -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)))
Expand Down
84 changes: 84 additions & 0 deletions tests/test_morpho_v2_governance.py
Original file line number Diff line number Diff line change
@@ -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()