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
29 changes: 29 additions & 0 deletions protocols/morpho/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
52 changes: 42 additions & 10 deletions protocols/morpho/governance_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
77 changes: 67 additions & 10 deletions protocols/morpho/v2_decoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}%"

Expand Down Expand Up @@ -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:
Expand All @@ -147,13 +178,31 @@ def decode_id_data(id_data: bytes, chain: Chain | None = None) -> str:
return f"<unparseable idData 0x{id_data.hex()}>"

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