From 3fb21f94d9589262cde49bbc6759b6e7826da8b4 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Sun, 29 Mar 2026 11:20:28 +0200 Subject: [PATCH 1/5] feat(yvusd): add yvUSD vault monitoring script Add monitoring for yvUSD vault covering: - APY inversion detection (unlocked > locked for extended periods) - Negative strategy APR alerts - CCTP cross-chain strategy staleness detection - Flashloan liquidity checks for Morpho looper unwinding - Large LockedyvUSD cooldown request detection Closes #193 Co-Authored-By: Claude Opus 4.6 (1M context) --- yvusd/__init__.py | 0 yvusd/abi/LockedYvUSD.json | 30 +++ yvusd/abi/Morpho.json | 30 +++ yvusd/abi/YearnV3Vault.json | 21 ++ yvusd/main.py | 392 ++++++++++++++++++++++++++++++++++++ 5 files changed, 473 insertions(+) create mode 100644 yvusd/__init__.py create mode 100644 yvusd/abi/LockedYvUSD.json create mode 100644 yvusd/abi/Morpho.json create mode 100644 yvusd/abi/YearnV3Vault.json create mode 100644 yvusd/main.py diff --git a/yvusd/__init__.py b/yvusd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yvusd/abi/LockedYvUSD.json b/yvusd/abi/LockedYvUSD.json new file mode 100644 index 0000000..ec59604 --- /dev/null +++ b/yvusd/abi/LockedYvUSD.json @@ -0,0 +1,30 @@ +[ + { + "anonymous": false, + "inputs": [ + {"indexed": true, "name": "user", "type": "address"}, + {"indexed": true, "name": "shares", "type": "uint256"}, + {"indexed": true, "name": "timestamp", "type": "uint256"} + ], + "name": "CooldownStarted", + "type": "event" + }, + { + "name": "getCooldownStatus", + "type": "function", + "inputs": [{"name": "user", "type": "address"}], + "outputs": [ + {"name": "cooldownEnd", "type": "uint256"}, + {"name": "windowEnd", "type": "uint256"}, + {"name": "shares", "type": "uint256"} + ], + "stateMutability": "view" + }, + { + "name": "totalSupply", + "type": "function", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view" + } +] diff --git a/yvusd/abi/Morpho.json b/yvusd/abi/Morpho.json new file mode 100644 index 0000000..e85c06d --- /dev/null +++ b/yvusd/abi/Morpho.json @@ -0,0 +1,30 @@ +[ + { + "name": "market", + "type": "function", + "inputs": [{"name": "id", "type": "bytes32"}], + "outputs": [ + {"name": "totalSupplyAssets", "type": "uint128"}, + {"name": "totalSupplyShares", "type": "uint128"}, + {"name": "totalBorrowAssets", "type": "uint128"}, + {"name": "totalBorrowShares", "type": "uint128"}, + {"name": "lastUpdate", "type": "uint128"}, + {"name": "fee", "type": "uint128"} + ], + "stateMutability": "view" + }, + { + "name": "position", + "type": "function", + "inputs": [ + {"name": "id", "type": "bytes32"}, + {"name": "user", "type": "address"} + ], + "outputs": [ + {"name": "supplyShares", "type": "uint256"}, + {"name": "borrowShares", "type": "uint128"}, + {"name": "collateral", "type": "uint128"} + ], + "stateMutability": "view" + } +] diff --git a/yvusd/abi/YearnV3Vault.json b/yvusd/abi/YearnV3Vault.json new file mode 100644 index 0000000..69b5681 --- /dev/null +++ b/yvusd/abi/YearnV3Vault.json @@ -0,0 +1,21 @@ +[ + { + "name": "strategies", + "type": "function", + "inputs": [{"name": "strategy", "type": "address"}], + "outputs": [ + {"name": "activation", "type": "uint256"}, + {"name": "last_report", "type": "uint256"}, + {"name": "current_debt", "type": "uint256"}, + {"name": "max_debt", "type": "uint256"} + ], + "stateMutability": "view" + }, + { + "name": "totalAssets", + "type": "function", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view" + } +] diff --git a/yvusd/main.py b/yvusd/main.py new file mode 100644 index 0000000..1ff76a7 --- /dev/null +++ b/yvusd/main.py @@ -0,0 +1,392 @@ +""" +yvUSD vault monitoring script. + +Monitors: +- APY anomalies: unlocked APY > locked APY inversion, negative strategy APR +- CCTP bridging delays: stale cross-chain strategy reports +- Flashloan liquidity: available liquidity for looper strategy unwinding +- Large cooldown requests: significant LockedyvUSD cooldown events +""" + +import time + +from utils.abi import load_abi +from utils.alert import Alert, AlertSeverity, send_alert +from utils.cache import get_last_value_for_key_from_file, write_last_value_to_file +from utils.chains import Chain +from utils.formatting import format_usd +from utils.http import fetch_json +from utils.logging import get_logger +from utils.web3_wrapper import ChainManager, Web3Client + +PROTOCOL = "yvusd" +logger = get_logger(PROTOCOL) + +CACHE_FILENAME = "cache-id.txt" + +# --- ABIs --- +ABI_VAULT = load_abi("yvusd/abi/YearnV3Vault.json") +ABI_MORPHO = load_abi("yvusd/abi/Morpho.json") +ABI_LOCKED = load_abi("yvusd/abi/LockedYvUSD.json") + +# --- Contract Addresses --- +YVUSD_VAULT = "0x696d02Db93291651ED510704c9b286841d506987" +LOCKED_YVUSD = "0xAaaFEa48472f77563961Cdb53291DEDfB46F9040" +MORPHO = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb" +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +BALANCER_VAULT = "0xBA12222222228d8Ba445958a75a0704d566BF2C8" + +# --- API --- +YVUSD_API_URL = "https://yvusd-api.yearn.fi/api/aprs" + +# --- Thresholds --- +APY_INVERSION_HOURS = 6 # Alert after this many hours of unlocked APY > locked APY +STRATEGY_STALENESS_HOURS = 48 # Cross-chain strategy report staleness threshold +LARGE_COOLDOWN_THRESHOLD = 100_000 # $100K in USD + +USDC_DECIMALS = 6 +ONE_USDC = 10**USDC_DECIMALS + +# --- Cache Keys --- +CACHE_KEY_APY_INVERSION_START = "YVUSD_APY_INVERSION_START" +CACHE_KEY_APY_INVERSION_ALERTED = "YVUSD_APY_INVERSION_ALERTED" +CACHE_KEY_LAST_BLOCK = "YVUSD_LAST_BLOCK" + +# Number of blocks to scan per run (~1 hour at 12s/block) +BLOCKS_PER_HOUR = 300 +MAX_SCAN_BLOCKS = 5000 + +# Minimal ERC20 ABI for balanceOf +ABI_ERC20_BALANCE = [ + { + "type": "function", + "name": "balanceOf", + "inputs": [{"name": "account", "type": "address"}], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + } +] + +# Strategy types that use Morpho leverage and need flashloans to unwind +LOOPER_STRATEGY_TYPES = ("morpho-looper", "pt-morpho-looper") + + +def get_cache_value(key: str) -> float: + """Read a cached float value, returns 0 if not found.""" + val = get_last_value_for_key_from_file(CACHE_FILENAME, key) + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + + +def set_cache_value(key: str, value: float) -> None: + """Write a float value to cache.""" + write_last_value_to_file(CACHE_FILENAME, key, value) + + +def check_apy_anomalies(api_data: dict) -> None: + """Check for APY anomalies using the yvUSD API. + + Alerts when: + - Unlocked APY > locked APY for more than APY_INVERSION_HOURS + - Any active strategy has negative APR + """ + yvusd_data = api_data.get(YVUSD_VAULT) + locked_data = api_data.get(LOCKED_YVUSD) + + if not yvusd_data or not locked_data: + logger.error("Missing vault data in API response") + send_alert(Alert(AlertSeverity.MEDIUM, "Missing vault data in yvUSD API response", PROTOCOL)) + return + + unlocked_apy = yvusd_data.get("apy", 0) + locked_apy = locked_data.get("apy", 0) + logger.info("APY — Unlocked: %.2f%%, Locked: %.2f%%", unlocked_apy * 100, locked_apy * 100) + + _check_apy_inversion(unlocked_apy, locked_apy) + _check_negative_strategy_apr(yvusd_data) + + +def _check_apy_inversion(unlocked_apy: float, locked_apy: float) -> None: + """Alert if unlocked APY exceeds locked APY for more than APY_INVERSION_HOURS.""" + now = time.time() + + if unlocked_apy > locked_apy: + inversion_start = get_cache_value(CACHE_KEY_APY_INVERSION_START) + if inversion_start == 0: + set_cache_value(CACHE_KEY_APY_INVERSION_START, now) + logger.warning( + "APY inversion detected: unlocked (%.2f%%) > locked (%.2f%%)", + unlocked_apy * 100, + locked_apy * 100, + ) + else: + hours_inverted = (now - inversion_start) / 3600 + already_alerted = get_cache_value(CACHE_KEY_APY_INVERSION_ALERTED) + if hours_inverted >= APY_INVERSION_HOURS and not already_alerted: + message = ( + f"*yvUSD APY Inversion Alert*\n" + f"Unlocked APY ({unlocked_apy:.2%}) > Locked APY ({locked_apy:.2%})\n" + f"Inverted for {hours_inverted:.1f} hours\n" + f"Locked users are earning less than unlocked — incentive misalignment\n" + f"[yvUSD Vault](https://etherscan.io/address/{YVUSD_VAULT})" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + set_cache_value(CACHE_KEY_APY_INVERSION_ALERTED, 1) + else: + # Inversion resolved — reset tracking + if get_cache_value(CACHE_KEY_APY_INVERSION_START) > 0: + set_cache_value(CACHE_KEY_APY_INVERSION_START, 0) + set_cache_value(CACHE_KEY_APY_INVERSION_ALERTED, 0) + logger.info("APY inversion resolved") + + +def _check_negative_strategy_apr(yvusd_data: dict) -> None: + """Alert if any active strategy has a negative APR.""" + strategies = yvusd_data.get("meta", {}).get("strategies", []) + + for strategy in strategies: + apr_raw = int(strategy.get("apr_raw", "0")) + debt = int(strategy.get("debt", "0")) + name = strategy.get("meta", {}).get("name", strategy.get("address", "unknown")) + address = strategy.get("address", "unknown") + + if debt > 0 and apr_raw < 0: + apr_pct = apr_raw / 1e18 * 100 + debt_usd = debt / ONE_USDC + message = ( + f"*yvUSD Negative Strategy APR*\n" + f"{name}: {apr_pct:.2f}% APR\n" + f"Debt: {format_usd(debt_usd)}\n" + f"Strategy is losing money\n" + f"[Strategy](https://etherscan.io/address/{address})" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + + +def check_strategy_staleness(client: Web3Client, api_data: dict) -> None: + """Check cross-chain strategy report freshness. + + Alerts when a CCTP cross-chain strategy hasn't reported + in more than STRATEGY_STALENESS_HOURS. + """ + strategies = api_data.get(YVUSD_VAULT, {}).get("meta", {}).get("strategies", []) + cross_chain = [s for s in strategies if s.get("meta", {}).get("type") == "cross-chain"] + + if not cross_chain: + logger.info("No cross-chain strategies found") + return + + vault = client.eth.contract(address=YVUSD_VAULT, abi=ABI_VAULT) + + with client.batch_requests() as batch: + for strategy in cross_chain: + batch.add(vault.functions.strategies(strategy["address"])) + responses = client.execute_batch(batch) + + if len(responses) != len(cross_chain): + logger.error("Unexpected batch response count for strategy staleness check") + return + + now = int(time.time()) + + for i, strategy in enumerate(cross_chain): + activation, last_report, current_debt, max_debt = responses[i] + name = strategy.get("meta", {}).get("name", strategy["address"]) + address = strategy["address"] + + if activation == 0: + continue + + hours_since_report = (now - last_report) / 3600 + debt_usd = current_debt / ONE_USDC + + logger.info( + "CCTP strategy %s — last report: %.1f hours ago, debt: %s", + name, + hours_since_report, + format_usd(debt_usd), + ) + + if current_debt > 0 and hours_since_report > STRATEGY_STALENESS_HOURS: + message = ( + f"*yvUSD CCTP Strategy Stale Report*\n" + f"{name}\n" + f"Last report: {hours_since_report:.1f} hours ago (threshold: {STRATEGY_STALENESS_HOURS}h)\n" + f"Debt: {format_usd(debt_usd)}\n" + f"Cross-chain accounting may be outdated\n" + f"[Strategy](https://etherscan.io/address/{address})" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + + +def check_flashloan_liquidity(client: Web3Client, api_data: dict) -> None: + """Check available flashloan liquidity for looper strategy unwinding. + + Compares each looper strategy's Morpho borrow position against available + flashloan liquidity from the Balancer vault and Morpho market. + """ + strategies = api_data.get(YVUSD_VAULT, {}).get("meta", {}).get("strategies", []) + loopers = [ + s + for s in strategies + if s.get("meta", {}).get("type") in LOOPER_STRATEGY_TYPES + and s.get("meta", {}).get("market_id") + and int(s.get("debt", "0")) > 0 + ] + + if not loopers: + logger.info("No active Morpho looper strategies found") + return + + morpho = client.eth.contract(address=MORPHO, abi=ABI_MORPHO) + usdc = client.eth.contract(address=USDC, abi=ABI_ERC20_BALANCE) + + with client.batch_requests() as batch: + for strategy in loopers: + market_id = bytes.fromhex(strategy["meta"]["market_id"][2:]) + batch.add(morpho.functions.market(market_id)) + batch.add(morpho.functions.position(market_id, strategy["address"])) + batch.add(usdc.functions.balanceOf(BALANCER_VAULT)) + responses = client.execute_batch(batch) + + expected = len(loopers) * 2 + 1 + if len(responses) != expected: + logger.error("Unexpected batch response count for flashloan liquidity check") + return + + balancer_usdc = responses[-1] / ONE_USDC + logger.info("Balancer vault USDC balance: %s", format_usd(balancer_usdc)) + + for i, strategy in enumerate(loopers): + market_data = responses[i * 2] + position_data = responses[i * 2 + 1] + + total_supply_assets = market_data[0] + total_borrow_assets = market_data[2] + total_borrow_shares = market_data[3] + borrow_shares = position_data[1] + + # Convert borrow shares to assets + if total_borrow_shares > 0 and borrow_shares > 0: + borrow_assets = borrow_shares * total_borrow_assets // total_borrow_shares + else: + borrow_assets = 0 + + borrow_usd = borrow_assets / ONE_USDC + market_liquidity = (total_supply_assets - total_borrow_assets) / ONE_USDC + name = strategy.get("meta", {}).get("name", strategy["address"]) + address = strategy["address"] + + logger.info( + "Looper %s — borrow: %s, market liquidity: %s", + name, + format_usd(borrow_usd), + format_usd(market_liquidity), + ) + + if borrow_assets == 0: + continue + + # Strategy needs to flashloan approximately borrow_assets to unwind. + # Alert if neither Balancer vault nor Morpho market has sufficient liquidity. + if balancer_usdc < borrow_usd and market_liquidity < borrow_usd: + message = ( + f"*yvUSD Flashloan Liquidity Warning*\n" + f"{name}\n" + f"Borrow position: {format_usd(borrow_usd)}\n" + f"Balancer flashloan available: {format_usd(balancer_usdc)}\n" + f"Morpho market liquidity: {format_usd(market_liquidity)}\n" + f"Insufficient flashloan liquidity for strategy unwinding\n" + f"[Strategy](https://etherscan.io/address/{address})" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + + +def check_large_cooldowns(client: Web3Client) -> None: + """Check for large cooldown requests on LockedyvUSD. + + Scans recent blocks for CooldownStarted events exceeding LARGE_COOLDOWN_THRESHOLD. + """ + locked = client.eth.contract(address=LOCKED_YVUSD, abi=ABI_LOCKED) + + current_block = client.eth.block_number + last_block = int(get_cache_value(CACHE_KEY_LAST_BLOCK)) + + if last_block == 0: + from_block = current_block - BLOCKS_PER_HOUR + else: + from_block = last_block + 1 + + if from_block >= current_block: + logger.info("No new blocks to scan for cooldown events") + set_cache_value(CACHE_KEY_LAST_BLOCK, current_block) + return + + # Cap scan range to avoid hitting RPC limits + if current_block - from_block > MAX_SCAN_BLOCKS: + from_block = current_block - MAX_SCAN_BLOCKS + logger.warning("Capped scan range to last %d blocks", MAX_SCAN_BLOCKS) + + logger.info("Scanning blocks %d to %d for cooldown events", from_block, current_block) + + try: + events = locked.events.CooldownStarted.get_logs(fromBlock=from_block, toBlock=current_block) + except Exception as e: + logger.warning("Could not fetch CooldownStarted events: %s", e) + set_cache_value(CACHE_KEY_LAST_BLOCK, current_block) + return + + large_count = 0 + for event in events: + shares = event["args"]["shares"] + owner = event["args"]["user"] + # yvUSD shares are roughly 1:1 with USDC (PPS ~ 1.004) + shares_usd = shares / ONE_USDC + + if shares_usd >= LARGE_COOLDOWN_THRESHOLD: + large_count += 1 + message = ( + f"*yvUSD Large Cooldown Request*\n" + f"{format_usd(shares_usd)} cooldown requested\n" + f"Owner: [{owner}](https://etherscan.io/address/{owner})\n" + f"Cooldown period: 14 days\n" + f"Large withdrawal incoming — may impact vault liquidity\n" + f"[LockedyvUSD](https://etherscan.io/address/{LOCKED_YVUSD})" + ) + send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + + logger.info("Found %d cooldown events (%d large)", len(events), large_count) + set_cache_value(CACHE_KEY_LAST_BLOCK, current_block) + + +def main() -> None: + """Run all yvUSD monitoring checks.""" + logger.info("Starting yvUSD monitoring...") + + client = ChainManager.get_client(Chain.MAINNET) + + try: + api_data = fetch_json(YVUSD_API_URL) + if api_data: + check_apy_anomalies(api_data) + check_strategy_staleness(client, api_data) + check_flashloan_liquidity(client, api_data) + else: + send_alert(Alert(AlertSeverity.MEDIUM, "Failed to fetch yvUSD API data", PROTOCOL)) + except Exception as e: + logger.error("Error during yvUSD API checks: %s", e) + send_alert(Alert(AlertSeverity.MEDIUM, f"yvUSD API checks failed: {e}", PROTOCOL)) + + try: + check_large_cooldowns(client) + except Exception as e: + logger.error("Error during cooldown check: %s", e) + send_alert(Alert(AlertSeverity.MEDIUM, f"yvUSD cooldown check failed: {e}", PROTOCOL)) + + logger.info("yvUSD monitoring complete") + + +if __name__ == "__main__": + main() From 02790dc16b230870eb16aa70abd3c1e0f801811a Mon Sep 17 00:00:00 2001 From: spalen0 Date: Sun, 29 Mar 2026 21:47:18 +0200 Subject: [PATCH 2/5] refactor(yvusd): move script into yearn folder Move yvusd monitoring into yearn/ to match project conventions: - yearn/yvusd.py (was yvusd/main.py) - yearn/abi/ for contract ABIs Co-Authored-By: Claude Opus 4.6 (1M context) --- {yvusd => yearn}/abi/LockedYvUSD.json | 0 {yvusd => yearn}/abi/Morpho.json | 0 {yvusd => yearn}/abi/YearnV3Vault.json | 0 yvusd/main.py => yearn/yvusd.py | 6 +++--- yvusd/__init__.py | 0 5 files changed, 3 insertions(+), 3 deletions(-) rename {yvusd => yearn}/abi/LockedYvUSD.json (100%) rename {yvusd => yearn}/abi/Morpho.json (100%) rename {yvusd => yearn}/abi/YearnV3Vault.json (100%) rename yvusd/main.py => yearn/yvusd.py (98%) delete mode 100644 yvusd/__init__.py diff --git a/yvusd/abi/LockedYvUSD.json b/yearn/abi/LockedYvUSD.json similarity index 100% rename from yvusd/abi/LockedYvUSD.json rename to yearn/abi/LockedYvUSD.json diff --git a/yvusd/abi/Morpho.json b/yearn/abi/Morpho.json similarity index 100% rename from yvusd/abi/Morpho.json rename to yearn/abi/Morpho.json diff --git a/yvusd/abi/YearnV3Vault.json b/yearn/abi/YearnV3Vault.json similarity index 100% rename from yvusd/abi/YearnV3Vault.json rename to yearn/abi/YearnV3Vault.json diff --git a/yvusd/main.py b/yearn/yvusd.py similarity index 98% rename from yvusd/main.py rename to yearn/yvusd.py index 1ff76a7..286c02b 100644 --- a/yvusd/main.py +++ b/yearn/yvusd.py @@ -25,9 +25,9 @@ CACHE_FILENAME = "cache-id.txt" # --- ABIs --- -ABI_VAULT = load_abi("yvusd/abi/YearnV3Vault.json") -ABI_MORPHO = load_abi("yvusd/abi/Morpho.json") -ABI_LOCKED = load_abi("yvusd/abi/LockedYvUSD.json") +ABI_VAULT = load_abi("yearn/abi/YearnV3Vault.json") +ABI_MORPHO = load_abi("yearn/abi/Morpho.json") +ABI_LOCKED = load_abi("yearn/abi/LockedYvUSD.json") # --- Contract Addresses --- YVUSD_VAULT = "0x696d02Db93291651ED510704c9b286841d506987" diff --git a/yvusd/__init__.py b/yvusd/__init__.py deleted file mode 100644 index e69de29..0000000 From 0e8306a8dd8d966575e809fa7f314cdbce0e8026 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Sun, 29 Mar 2026 21:55:33 +0200 Subject: [PATCH 3/5] fix(yvusd): wire monitor and harden bridge checks --- .github/workflows/hourly.yml | 1 + tests/test_yvusd.py | 132 +++++++++++++++++++++++++++++++++++ yearn/yvusd.py | 116 ++++++++++++++++++++++++------ 3 files changed, 228 insertions(+), 21 deletions(-) create mode 100644 tests/test_yvusd.py diff --git a/.github/workflows/hourly.yml b/.github/workflows/hourly.yml index 5fe6056..5d72111 100644 --- a/.github/workflows/hourly.yml +++ b/.github/workflows/hourly.yml @@ -29,6 +29,7 @@ jobs: silo/ur_sniff.py usdai/main.py yearn/alert_large_flows.py + yearn/yvusd.py maple/main.py timelock/timelock_alerts.py # always run proposals after timelock alerts diff --git a/tests/test_yvusd.py b/tests/test_yvusd.py new file mode 100644 index 0000000..102507f --- /dev/null +++ b/tests/test_yvusd.py @@ -0,0 +1,132 @@ +import unittest +from unittest.mock import MagicMock, patch + +from utils.chains import Chain +from yearn.yvusd import ( + CCTP_REPORT_SKEW_HOURS, + CCTP_REPORT_STALENESS_HOURS, + check_large_cooldowns, + check_strategy_staleness, +) + + +class TestYvUsdCctpChecks(unittest.TestCase): + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_alerts_on_report_skew_between_local_and_remote(self, mock_get_client: MagicMock, mock_send_alert: MagicMock): + now = 1_000_000 + local_last_report = now - 3600 + remote_last_report = now - int((CCTP_REPORT_SKEW_HOURS + 2) * 3600) + + remote_vault = MagicMock() + remote_vault.functions.strategies.return_value.call.return_value = ( + 1, + remote_last_report, + 100_000_000, + 0, + ) + remote_client = MagicMock() + remote_client.eth.contract.return_value = remote_vault + mock_get_client.return_value = remote_client + + mainnet_vault = MagicMock() + client = MagicMock() + client.eth.contract.return_value = mainnet_vault + client.batch_requests.return_value.__enter__.return_value = MagicMock() + client.batch_requests.return_value.__exit__.return_value = False + client.execute_batch.return_value = [(1, local_last_report, 100_000_000, 0)] + + api_data = { + "0x696d02Db93291651ED510704c9b286841d506987": { + "meta": { + "strategies": [ + { + "address": "0x1983923e5a3591AFe036d38A8C8011e66Cd76e9E", + "meta": { + "name": "Arbitrum Yearn Degen Morpho Compounder", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0x78b7774c4368df8f2c115Abf6210F557753a6aC5", + "remote_counterpart": "0xaDa882B1BcB9B658b354ade0cE64586A88cb6849", + }, + } + ] + } + } + } + + with patch("yearn.yvusd.time.time", return_value=now): + check_strategy_staleness(client, api_data) + + mock_send_alert.assert_called_once() + message = mock_send_alert.call_args.args[0].message + self.assertIn("report skew", message) + self.assertIn("Arbitrum Yearn Degen Morpho Compounder", message) + + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_alerts_on_remote_staleness(self, mock_get_client: MagicMock, mock_send_alert: MagicMock): + now = 1_000_000 + stale_seconds = int((CCTP_REPORT_STALENESS_HOURS + 1) * 3600) + + remote_vault = MagicMock() + remote_vault.functions.strategies.return_value.call.return_value = ( + 1, + now - stale_seconds, + 200_000_000, + 0, + ) + remote_client = MagicMock() + remote_client.eth.contract.return_value = remote_vault + mock_get_client.return_value = remote_client + + client = MagicMock() + client.eth.contract.return_value = MagicMock() + client.batch_requests.return_value.__enter__.return_value = MagicMock() + client.batch_requests.return_value.__exit__.return_value = False + client.execute_batch.return_value = [(1, now - 3600, 100_000_000, 0)] + + api_data = { + "0x696d02Db93291651ED510704c9b286841d506987": { + "meta": { + "strategies": [ + { + "address": "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", + "meta": { + "name": "Arbitrum syrupUSDC/USDC Morpho Looper", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", + "remote_counterpart": "0xAA442539f43d9A864e26e56E5C8Ee791E9Df7dA2", + }, + } + ] + } + } + } + + with patch("yearn.yvusd.time.time", return_value=now): + check_strategy_staleness(client, api_data) + + mock_send_alert.assert_called_once() + self.assertIn("report stale", mock_send_alert.call_args.args[0].message) + + +class TestYvUsdCooldownScanning(unittest.TestCase): + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=123) + def test_does_not_advance_cache_when_log_fetch_fails(self, mock_get_cache: MagicMock, mock_set_cache: MagicMock): + client = MagicMock() + client.eth.block_number = 200 + + locked = MagicMock() + locked.events.CooldownStarted.get_logs.side_effect = RuntimeError("rpc failure") + client.eth.contract.return_value = locked + + check_large_cooldowns(client) + + mock_set_cache.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/yearn/yvusd.py b/yearn/yvusd.py index 286c02b..23a0f2f 100644 --- a/yearn/yvusd.py +++ b/yearn/yvusd.py @@ -3,7 +3,7 @@ Monitors: - APY anomalies: unlocked APY > locked APY inversion, negative strategy APR -- CCTP bridging delays: stale cross-chain strategy reports +- CCTP bridging delays: stale or out-of-sync cross-chain strategy reports - Flashloan liquidity: available liquidity for looper strategy unwinding - Large cooldown requests: significant LockedyvUSD cooldown events """ @@ -19,8 +19,8 @@ from utils.logging import get_logger from utils.web3_wrapper import ChainManager, Web3Client -PROTOCOL = "yvusd" -logger = get_logger(PROTOCOL) +PROTOCOL = "yearn" +logger = get_logger("yvusd") CACHE_FILENAME = "cache-id.txt" @@ -41,7 +41,8 @@ # --- Thresholds --- APY_INVERSION_HOURS = 6 # Alert after this many hours of unlocked APY > locked APY -STRATEGY_STALENESS_HOURS = 48 # Cross-chain strategy report staleness threshold +CCTP_REPORT_STALENESS_HOURS = 48 # Report freshness threshold +CCTP_REPORT_SKEW_HOURS = 6 # Max allowed skew between local and remote reports LARGE_COOLDOWN_THRESHOLD = 100_000 # $100K in USD USDC_DECIMALS = 6 @@ -168,8 +169,9 @@ def _check_negative_strategy_apr(yvusd_data: dict) -> None: def check_strategy_staleness(client: Web3Client, api_data: dict) -> None: """Check cross-chain strategy report freshness. - Alerts when a CCTP cross-chain strategy hasn't reported - in more than STRATEGY_STALENESS_HOURS. + Alerts when a CCTP cross-chain strategy or its remote counterpart: + - has not reported in more than CCTP_REPORT_STALENESS_HOURS, or + - is out of sync with the other side by more than CCTP_REPORT_SKEW_HOURS """ strategies = api_data.get(YVUSD_VAULT, {}).get("meta", {}).get("strategies", []) cross_chain = [s for s in strategies if s.get("meta", {}).get("type") == "cross-chain"] @@ -191,36 +193,109 @@ def check_strategy_staleness(client: Web3Client, api_data: dict) -> None: now = int(time.time()) - for i, strategy in enumerate(cross_chain): - activation, last_report, current_debt, max_debt = responses[i] + for strategy, local_state in zip(cross_chain, responses, strict=False): + activation, local_last_report, local_debt, _ = local_state name = strategy.get("meta", {}).get("name", strategy["address"]) address = strategy["address"] + meta = strategy.get("meta", {}) + remote_chain_id = meta.get("remote_chain_id") + remote_vault = meta.get("remote_vault") + remote_counterpart = meta.get("remote_counterpart") - if activation == 0: + if activation == 0 or not remote_chain_id or not remote_vault or not remote_counterpart: continue - hours_since_report = (now - last_report) / 3600 - debt_usd = current_debt / ONE_USDC + try: + remote_chain = Chain.from_chain_id(remote_chain_id) + remote_client = ChainManager.get_client(remote_chain) + remote_contract = remote_client.eth.contract(address=remote_vault, abi=ABI_VAULT) + remote_activation, remote_last_report, remote_debt, _ = remote_contract.functions.strategies( + remote_counterpart + ).call() + except Exception as e: + logger.warning("Could not fetch remote counterpart state for %s: %s", name, e) + continue + + if remote_activation == 0: + continue + + local_hours_since = (now - local_last_report) / 3600 + remote_hours_since = (now - remote_last_report) / 3600 + report_skew_hours = abs(local_last_report - remote_last_report) / 3600 + local_debt_usd = local_debt / ONE_USDC + remote_debt_usd = remote_debt / ONE_USDC logger.info( - "CCTP strategy %s — last report: %.1f hours ago, debt: %s", + "CCTP strategy %s — local report: %.1fh, remote report: %.1fh, skew: %.1fh, local debt: %s, remote debt: %s", name, - hours_since_report, - format_usd(debt_usd), + local_hours_since, + remote_hours_since, + report_skew_hours, + format_usd(local_debt_usd), + format_usd(remote_debt_usd), ) - if current_debt > 0 and hours_since_report > STRATEGY_STALENESS_HOURS: + alert_lines = _build_cctp_alert_lines( + name=name, + local_chain=Chain.MAINNET, + local_last_report=local_last_report, + local_debt=local_debt, + remote_chain=remote_chain, + remote_last_report=remote_last_report, + remote_debt=remote_debt, + now=now, + ) + if alert_lines: message = ( - f"*yvUSD CCTP Strategy Stale Report*\n" - f"{name}\n" - f"Last report: {hours_since_report:.1f} hours ago (threshold: {STRATEGY_STALENESS_HOURS}h)\n" - f"Debt: {format_usd(debt_usd)}\n" - f"Cross-chain accounting may be outdated\n" + "*yvUSD CCTP Bridge Health Alert*\n" + + "\n".join(alert_lines) + + "\n" f"[Strategy](https://etherscan.io/address/{address})" ) send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) +def _build_cctp_alert_lines( + *, + name: str, + local_chain: Chain, + local_last_report: int, + local_debt: int, + remote_chain: Chain, + remote_last_report: int, + remote_debt: int, + now: int, +) -> list[str]: + local_hours_since = (now - local_last_report) / 3600 + remote_hours_since = (now - remote_last_report) / 3600 + report_skew_hours = abs(local_last_report - remote_last_report) / 3600 + has_position = local_debt > 0 or remote_debt > 0 + if not has_position: + return [] + + problems = [] + if local_debt > 0 and local_hours_since > CCTP_REPORT_STALENESS_HOURS: + problems.append(f"{local_chain.network_name} report stale: {local_hours_since:.1f}h") + if remote_debt > 0 and remote_hours_since > CCTP_REPORT_STALENESS_HOURS: + problems.append(f"{remote_chain.network_name} report stale: {remote_hours_since:.1f}h") + if report_skew_hours > CCTP_REPORT_SKEW_HOURS: + newer_chain = local_chain if local_last_report >= remote_last_report else remote_chain + problems.append( + f"report skew {report_skew_hours:.1f}h ({newer_chain.network_name} is newer than the other side)" + ) + + if not problems: + return [] + + return [ + name, + *problems, + f"Mainnet last report: {local_hours_since:.1f}h ago, debt: {format_usd(local_debt / ONE_USDC)}", + f"{remote_chain.network_name.title()} last report: {remote_hours_since:.1f}h ago, debt: {format_usd(remote_debt / ONE_USDC)}", + "Bridge accounting may be delayed or unsynced", + ] + + def check_flashloan_liquidity(client: Web3Client, api_data: dict) -> None: """Check available flashloan liquidity for looper strategy unwinding. @@ -335,7 +410,6 @@ def check_large_cooldowns(client: Web3Client) -> None: events = locked.events.CooldownStarted.get_logs(fromBlock=from_block, toBlock=current_block) except Exception as e: logger.warning("Could not fetch CooldownStarted events: %s", e) - set_cache_value(CACHE_KEY_LAST_BLOCK, current_block) return large_count = 0 From 4c96c500620f6775ba9791c08478b7554ade1852 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Sun, 29 Mar 2026 22:00:54 +0200 Subject: [PATCH 4/5] style: linter --- tests/test_yvusd.py | 4 +++- yearn/yvusd.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_yvusd.py b/tests/test_yvusd.py index 102507f..f5e1c71 100644 --- a/tests/test_yvusd.py +++ b/tests/test_yvusd.py @@ -13,7 +13,9 @@ class TestYvUsdCctpChecks(unittest.TestCase): @patch("yearn.yvusd.send_alert") @patch("yearn.yvusd.ChainManager.get_client") - def test_alerts_on_report_skew_between_local_and_remote(self, mock_get_client: MagicMock, mock_send_alert: MagicMock): + def test_alerts_on_report_skew_between_local_and_remote( + self, mock_get_client: MagicMock, mock_send_alert: MagicMock + ): now = 1_000_000 local_last_report = now - 3600 remote_last_report = now - int((CCTP_REPORT_SKEW_HOURS + 2) * 3600) diff --git a/yearn/yvusd.py b/yearn/yvusd.py index 23a0f2f..88b33bf 100644 --- a/yearn/yvusd.py +++ b/yearn/yvusd.py @@ -247,9 +247,7 @@ def check_strategy_staleness(client: Web3Client, api_data: dict) -> None: ) if alert_lines: message = ( - "*yvUSD CCTP Bridge Health Alert*\n" - + "\n".join(alert_lines) - + "\n" + "*yvUSD CCTP Bridge Health Alert*\n" + "\n".join(alert_lines) + "\n" f"[Strategy](https://etherscan.io/address/{address})" ) send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) From 80f187fc830bb4a632f0fcc9ef6fdd9f61e0b1df Mon Sep 17 00:00:00 2001 From: spalen0 Date: Sun, 29 Mar 2026 22:03:02 +0200 Subject: [PATCH 5/5] refactor(yvusd): reuse shared morpho abi --- yearn/abi/Morpho.json | 30 ------------------------------ yearn/yvusd.py | 2 +- 2 files changed, 1 insertion(+), 31 deletions(-) delete mode 100644 yearn/abi/Morpho.json diff --git a/yearn/abi/Morpho.json b/yearn/abi/Morpho.json deleted file mode 100644 index e85c06d..0000000 --- a/yearn/abi/Morpho.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "name": "market", - "type": "function", - "inputs": [{"name": "id", "type": "bytes32"}], - "outputs": [ - {"name": "totalSupplyAssets", "type": "uint128"}, - {"name": "totalSupplyShares", "type": "uint128"}, - {"name": "totalBorrowAssets", "type": "uint128"}, - {"name": "totalBorrowShares", "type": "uint128"}, - {"name": "lastUpdate", "type": "uint128"}, - {"name": "fee", "type": "uint128"} - ], - "stateMutability": "view" - }, - { - "name": "position", - "type": "function", - "inputs": [ - {"name": "id", "type": "bytes32"}, - {"name": "user", "type": "address"} - ], - "outputs": [ - {"name": "supplyShares", "type": "uint256"}, - {"name": "borrowShares", "type": "uint128"}, - {"name": "collateral", "type": "uint128"} - ], - "stateMutability": "view" - } -] diff --git a/yearn/yvusd.py b/yearn/yvusd.py index 88b33bf..371e299 100644 --- a/yearn/yvusd.py +++ b/yearn/yvusd.py @@ -26,7 +26,7 @@ # --- ABIs --- ABI_VAULT = load_abi("yearn/abi/YearnV3Vault.json") -ABI_MORPHO = load_abi("yearn/abi/Morpho.json") +ABI_MORPHO = load_abi("morpho/abi/morpho.json") ABI_LOCKED = load_abi("yearn/abi/LockedYvUSD.json") # --- Contract Addresses ---