diff --git a/protocols/morpho/_shared.py b/protocols/morpho/_shared.py index 911c258..c102013 100644 --- a/protocols/morpho/_shared.py +++ b/protocols/morpho/_shared.py @@ -134,6 +134,35 @@ def fetch_market_metadata(market_id: str, chain: Chain) -> dict[str, Any] | None return None +def fetch_asset_metadata(address: str, chain: Chain) -> dict[str, Any] | None: + """Fetch the symbol and decimals for an ERC-20 token by address. + + Uses Morpho's ``assetByAddress`` query. Returns None on error so alert + rendering can fall back to the raw address. + """ + query = """ + query GetAsset($address: String!, $chainId: Int!) { + assetByAddress(address: $address, chainId: $chainId) { + symbol + decimals + } + } + """ + try: + data = execute_graphql( + query, + {"address": address, "chainId": chain.chain_id}, + f"asset metadata for {address} on {chain.name}", + ) + asset = data.get("assetByAddress") + if not asset: + return None + return {"symbol": asset["symbol"], "decimals": int(asset["decimals"])} + except Exception as e: + logger.warning("Failed to fetch asset metadata for %s: %s", address, e) + return None + + def fetch_market_name(market_id: str, chain: Chain) -> str: """Fetch a human-readable name like 'WBTC/USDC' for a market_id. diff --git a/protocols/morpho/governance_v2.py b/protocols/morpho/governance_v2.py index 7828119..64a1c8e 100644 --- a/protocols/morpho/governance_v2.py +++ b/protocols/morpho/governance_v2.py @@ -272,18 +272,46 @@ def _pending_function_key(snapshot: V2GovernanceSnapshot, data_hash: str) -> str return str(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" +def _alert_pending_new(snapshot: V2GovernanceSnapshot, pending: List[tuple[PendingConfig, str]]) -> None: + """Alert on newly-submitted timelocked operation(s) for a single vault. + + Multiple operations submitted on the same vault (e.g. a batched multicall + submit) are grouped into one Telegram message. When every operation shares + the same execution time and tx hash, those are shown once in the footer; + otherwise they are rendered per operation. + """ + if not pending: + return + + header = f"⏳ V2 [{snapshot.name}]({get_vault_url(snapshot.address, snapshot.chain)}) on {snapshot.chain.name}" + + if len(pending) == 1: + pc, operation_label = pending[0] + message = ( + f"{header}\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, + f"🔗 Tx: {_explorer_link(snapshot.chain, pc.tx_hash)}" ) - ) + send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) + return + + lines = [header, f"📥 Submitted {len(pending)} operations:"] + shared_valid_at = len({pc.valid_at for pc, _ in pending}) == 1 + shared_tx = len({pc.tx_hash for pc, _ in pending}) == 1 + if shared_valid_at and shared_tx: + for _, operation_label in pending: + lines.append(f" • {operation_label}") + pc0 = pending[0][0] + lines.append(f"⏰ Executable at: {_format_ts(pc0.valid_at)} {_format_countdown(pc0.valid_at)}") + lines.append(f"🔗 Tx: {_explorer_link(snapshot.chain, pc0.tx_hash)}") + else: + for pc, operation_label in pending: + lines.append(f" • {operation_label}") + lines.append(f" ⏰ Executable at: {_format_ts(pc.valid_at)} {_format_countdown(pc.valid_at)}") + lines.append(f" 🔗 Tx: {_explorer_link(snapshot.chain, pc.tx_hash)}") + + send_alert(Alert(AlertSeverity.MEDIUM, "\n".join(lines), PROTOCOL)) def _alert_pending_resolved( @@ -358,6 +386,7 @@ def _diff_pending(snapshot: V2GovernanceSnapshot) -> None: addr = snapshot.address.lower() current_keys: set[str] = set() + new_pending: List[tuple[PendingConfig, str]] = [] for pc in snapshot.pending_configs: current_keys.add(pc.data_hash) operation_label = _operation_label(snapshot, pc) @@ -367,9 +396,12 @@ 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) + new_pending.append((pc, operation_label)) _write(cache_key, pc.valid_at) + # Group all newly-submitted operations for this vault into one alert. + _alert_pending_new(snapshot, new_pending) + # Detect resolved entries: anything in last-run's index that isn't in the # current pending list. index_key = morpho_key(addr, "pending_keys", PENDING_INDEX_TYPE) diff --git a/protocols/morpho/v2_decoders.py b/protocols/morpho/v2_decoders.py index fa1ffa1..3f05722 100644 --- a/protocols/morpho/v2_decoders.py +++ b/protocols/morpho/v2_decoders.py @@ -18,7 +18,7 @@ from eth_utils import to_checksum_address from web3 import Web3 -from protocols.morpho._shared import fetch_market_metadata, get_market_url +from protocols.morpho._shared import fetch_asset_metadata, fetch_market_metadata, get_market_url from utils.calldata.decoder import resolve_selector from utils.chains import Chain from utils.formatting import format_token_amount, format_with_suffix @@ -90,6 +90,37 @@ def _format_address(addr: str) -> str: return to_checksum_address(addr) +def _address_link(addr: str, chain: Chain | None) -> str: + """Render a checksummed address as a Markdown link to the chain's explorer. + + Falls back to the bare checksummed address when no explorer is configured or + the chain is unknown (e.g. when decoding without chain context). + """ + checksummed = _format_address(addr) + base = chain.explorer_url if chain else None + if not base: + return checksummed + return f"[{checksummed}]({base}/address/{checksummed})" + + +def _token_link(addr: str, chain: Chain | None) -> str: + """Render a token as ``[SYMBOL](explorer/address)``, resolving the symbol. + + Fetches the ERC-20 symbol via Morpho's asset API when a chain is available; + falls back to the bare checksummed-address link (``_address_link``) when the + symbol can't be resolved or no chain is provided. + """ + checksummed = _format_address(addr) + base = chain.explorer_url if chain else None + if chain is None or not base: + return _address_link(addr, chain) + metadata = fetch_asset_metadata(addr, chain) + symbol = metadata["symbol"] if metadata and metadata.get("symbol") else None + if not symbol: + return _address_link(addr, chain) + return f"[{symbol}]({base}/address/{checksummed})" + + def _format_wad_pct(value: int) -> str: return f"{value / WAD * 100:.4f}%" @@ -137,8 +168,8 @@ def decode_id_data(id_data: bytes, chain: Chain | None = None) -> str: return f"market [{metadata['name']}]({get_market_url(market_id, chain)})" return ( f"market `{market_id}` " - f"(loan {_format_address(loan)}, collateral {_format_address(collateral)}, " - f"lltv {lltv / WAD * 100:.2f}%) on adapter {_format_address(adapter)}" + f"(loan {_address_link(loan, chain)}, collateral {_address_link(collateral, chain)}, " + f"lltv {lltv / WAD * 100:.2f}%) on adapter {_address_link(adapter, chain)}" ) try: @@ -147,13 +178,31 @@ def decode_id_data(id_data: bytes, chain: Chain | None = None) -> str: return f"" if tag == "this": - return f"adapterId for adapter {_format_address(addr)}" + return f"adapterId for adapter {_address_link(addr, chain)}" if tag == "collateralToken": - return f"collateral token {_format_address(addr)}" - return f"id tag '{tag}' addr {_format_address(addr)}" + return f"collateral token {_token_link(addr, chain)}" + return f"id tag '{tag}' addr {_address_link(addr, chain)}" + + +def _format_cap_value( + new_cap: int, + *, + is_relative: bool, + decimals: int | None = None, + symbol: str | None = None, +) -> str: + """Render a cap value. + + Relative caps (``increaseRelativeCap``) are WAD-scaled ratios of the vault's + total assets (``1e18`` = 100%), so they render as a percentage. Absolute caps + are token amounts and render with the asset's decimals/symbol when known. + """ + if is_relative: + return _format_wad_pct(new_cap) + return _format_cap_amount(new_cap, decimals, symbol) -def _format_cap_change(id_data: bytes, new_cap: int, chain: Chain | None = None) -> str: +def _format_cap_change(id_data: bytes, new_cap: int, chain: Chain | None = None, *, is_relative: bool = False) -> str: market_params_type = "(address,address,address,address,uint256)" if chain is not None: try: @@ -166,10 +215,18 @@ def _format_cap_change(id_data: bytes, new_cap: int, chain: Chain | None = None) market_id = _market_id_from_params(loan, collateral, oracle, irm, lltv) metadata = fetch_market_metadata(market_id, chain) if metadata: - cap = _format_cap_amount(new_cap, metadata["loan_decimals"], metadata["loan_symbol"]) + cap = _format_cap_value( + new_cap, + is_relative=is_relative, + decimals=metadata["loan_decimals"], + symbol=metadata["loan_symbol"], + ) return f"market [{metadata['name']}]({get_market_url(market_id, chain)}) → cap {cap}" - return f"{decode_id_data(id_data)} → cap {new_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}" + return f"{decode_id_data(id_data, chain)} → cap {cap}" def _encode_market_params(loan: str, collateral: str, oracle: str, irm: str, lltv: int) -> bytes: @@ -207,7 +264,7 @@ 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) + return _format_cap_change(id_data, new_cap, chain, is_relative=(name == "increaseRelativeCap")) if name in ("increaseTimelock", "decreaseTimelock"): sel_bytes, duration = args return f"{_resolve_inner_selector(sel_bytes)} → {duration}s" diff --git a/tests/test_morpho_v2_decoders.py b/tests/test_morpho_v2_decoders.py index 88c03be..4e30dad 100644 --- a/tests/test_morpho_v2_decoders.py +++ b/tests/test_morpho_v2_decoders.py @@ -219,6 +219,27 @@ def test_increase_absolute_cap_with_market_params_and_chain_links_market(self): self.assertNotIn(f"adapter {Web3.to_checksum_address(A5)}", decoded) self.assertIn("cap 80.00M RLUSD", decoded) + def test_increase_relative_cap_with_market_params_renders_pct(self): + market_params = (A1, A2, A3, A4, 91 * 10**16) + id_data = abi_encode( + ["string", "address", "(address,address,address,address,uint256)"], + ["this/marketParams", A5, market_params], + ) + data = _build( + "increaseRelativeCap(bytes,uint256)", + ["bytes", "uint256"], + [id_data, 10**18], # 1e18 WAD = 100% + ) + + metadata = {"name": "weETH/vbUSDC", "loan_symbol": "vbUSDC", "loan_decimals": 6} + with patch("protocols.morpho.v2_decoders.fetch_market_metadata", return_value=metadata): + decoded = decode_submit(data, Chain.MAINNET) + + self.assertIn("market [weETH/vbUSDC]", decoded) + # Relative cap → percentage, not a misleading token amount. + self.assertIn("cap 100.0000%", decoded) + self.assertNotIn("vbUSDC)", decoded) + def test_increase_relative_cap_with_collateral_tag(self): id_data = abi_encode(["string", "address"], ["collateralToken", A1]) data = _build( @@ -228,7 +249,34 @@ def test_increase_relative_cap_with_collateral_tag(self): ) decoded = decode_submit(data) self.assertIn("increaseRelativeCap", decoded) + # No chain → symbol/link can't be resolved; falls back to bare address. self.assertIn(f"collateral token {Web3.to_checksum_address(A1)}", decoded) + # Relative cap renders as a percentage (5e17 WAD = 50%). + self.assertIn("cap 50.0000%", decoded) + + def test_collateral_token_links_symbol_and_renders_relative_cap_pct(self): + id_data = abi_encode(["string", "address"], ["collateralToken", A1]) + data = _build( + "increaseRelativeCap(bytes,uint256)", + ["bytes", "uint256"], + [id_data, 10**18], # 1e18 WAD = 100% + ) + addr = Web3.to_checksum_address(A1) + metadata = {"symbol": "weETH", "decimals": 18} + with patch("protocols.morpho.v2_decoders.fetch_asset_metadata", return_value=metadata) as fetch: + decoded = decode_submit(data, Chain.KATANA) + + fetch.assert_called_once() + self.assertIn(f"collateral token [weETH](https://katanascan.com/address/{addr})", decoded) + self.assertIn("cap 100.0000%", decoded) + + def test_collateral_token_falls_back_to_address_when_symbol_unresolved(self): + id_data = abi_encode(["string", "address"], ["collateralToken", A1]) + data = _build("increaseRelativeCap(bytes,uint256)", ["bytes", "uint256"], [id_data, 10**18]) + addr = Web3.to_checksum_address(A1) + with patch("protocols.morpho.v2_decoders.fetch_asset_metadata", return_value=None): + decoded = decode_submit(data, Chain.KATANA) + self.assertIn(f"collateral token [{addr}](https://katanascan.com/address/{addr})", decoded) class TestSubmitDataKey(unittest.TestCase): diff --git a/tests/test_morpho_v2_governance.py b/tests/test_morpho_v2_governance.py index ae5bccf..70ed8a7 100644 --- a/tests/test_morpho_v2_governance.py +++ b/tests/test_morpho_v2_governance.py @@ -81,6 +81,69 @@ def test_resolved_pending_alert_without_cached_function_keeps_hash_only_message( self.assertNotIn(f"(`{data_hash[:10]}…`)", alert.message) +class TestMorphoV2GovernancePendingGrouping(unittest.TestCase): + def test_multiple_new_pending_grouped_into_single_alert(self) -> None: + state: dict[str, str] = {} + + def read_value(_filename: str, key: str) -> str | int: + return state.get(key, 0) + + def write_value(_filename: str, key: str, value: object) -> None: + state[key] = str(value) + + tx = "0x" + "12" * 32 + data_a = _build("addAdapter(address)", ["address"], [A1]) + data_b = _build("removeAdapter(address)", ["address"], [A1]) + pcs = [ + PendingConfig(valid_at=100, function_name="addAdapter", data=data_a, tx_hash=tx), + PendingConfig(valid_at=100, function_name="removeAdapter", data=data_b, tx_hash=tx), + ] + + 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, + ): + governance_v2._diff_pending(_snapshot(pcs)) + + # Both submissions collapse into one Telegram message. + self.assertEqual(send.call_count, 1) + message = send.call_args.args[0].message + self.assertIn("Submitted 2 operations:", message) + self.assertIn("addAdapter", message) + self.assertIn("removeAdapter", message) + # Shared execution time / tx are rendered once in the footer. + self.assertEqual(message.count("⏰ Executable at:"), 1) + self.assertEqual(message.count("🔗 Tx:"), 1) + + def test_single_new_pending_uses_unnumbered_format(self) -> None: + state: dict[str, str] = {} + + with ( + patch( + "protocols.morpho.governance_v2.get_last_value_for_key_from_file", + side_effect=lambda _f, key: state.get(key, 0), + ), + patch( + "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, + ): + pc = PendingConfig( + valid_at=100, + function_name="addAdapter", + data=_build("addAdapter(address)", ["address"], [A1]), + tx_hash="0x" + "12" * 32, + ) + governance_v2._diff_pending(_snapshot([pc])) + + self.assertEqual(send.call_count, 1) + message = send.call_args.args[0].message + self.assertIn("📥 Submitted: addAdapter", message) + self.assertNotIn("operations:", message) + + class TestMorphoV2GovernanceFetch(unittest.TestCase): def test_fetch_fails_if_api_omits_configured_vaults(self) -> None: response = MagicMock()