From 3fb21f94d9589262cde49bbc6759b6e7826da8b4 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Sun, 29 Mar 2026 11:20:28 +0200 Subject: [PATCH 1/8] 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 00000000..e69de29b diff --git a/yvusd/abi/LockedYvUSD.json b/yvusd/abi/LockedYvUSD.json new file mode 100644 index 00000000..ec59604f --- /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 00000000..e85c06d6 --- /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 00000000..69b56815 --- /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 00000000..1ff76a7a --- /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/8] 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 1ff76a7a..286c02b2 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 e69de29b..00000000 From 0e8306a8dd8d966575e809fa7f314cdbce0e8026 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Sun, 29 Mar 2026 21:55:33 +0200 Subject: [PATCH 3/8] 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 5fe60561..5d721111 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 00000000..102507f7 --- /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 286c02b2..23a0f2fa 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/8] 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 102507f7..f5e1c710 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 23a0f2fa..88b33bf9 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/8] 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 e85c06d6..00000000 --- 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 88b33bf9..371e2995 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 --- From 320e30e112640c5f154c14e421819374200f211c Mon Sep 17 00:00:00 2001 From: spalen0 Date: Thu, 23 Apr 2026 08:24:32 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix(yvusd):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20remote=20ABI,=20looper=20coverage,=20get=5Flogs=20k?= =?UTF-8?q?wargs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remote cross-chain "vaults" (0x78b7…, 0xBCf0…) are V3 tokenized strategies and revert on strategies(); switch to direct lastReport() / totalAssets() calls and surface a MEDIUM alert on lookup failure instead of silently disabling the CCTP health check. - Flashloan liquidity check now picks up cross-chain wrappers whose remote_vault_type is a looper (borrower = remote_vault on the remote chain) and runs per-chain via LOOPER_CHAIN_CONFIG (mainnet + Arbitrum). - events.CooldownStarted.get_logs now uses snake_case from_block / to_block for web3 7.x; camelCase raised TypeError and silently returned. - Add morpho/abi/morpho_blue.json — the previous shared-abi refactor pointed ABI_MORPHO at the MetaMorpho vault ABI, which lacks market()/position() and would have raised ABIFunctionNotFound. Co-Authored-By: Claude Opus 4.7 --- morpho/abi/morpho_blue.json | 30 ++++ tests/test_yvusd.py | 238 +++++++++++++++++++++++++++--- yearn/yvusd.py | 282 ++++++++++++++++++++++++++++++------ 3 files changed, 485 insertions(+), 65 deletions(-) create mode 100644 morpho/abi/morpho_blue.json diff --git a/morpho/abi/morpho_blue.json b/morpho/abi/morpho_blue.json new file mode 100644 index 00000000..e85c06d6 --- /dev/null +++ b/morpho/abi/morpho_blue.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/tests/test_yvusd.py b/tests/test_yvusd.py index f5e1c710..6aa1bc02 100644 --- a/tests/test_yvusd.py +++ b/tests/test_yvusd.py @@ -5,11 +5,24 @@ from yearn.yvusd import ( CCTP_REPORT_SKEW_HOURS, CCTP_REPORT_STALENESS_HOURS, + LOOPER_CHAIN_CONFIG, + YVUSD_VAULT, + LooperPosition, + _collect_looper_positions, + check_flashloan_liquidity, check_large_cooldowns, check_strategy_staleness, ) +def _make_remote_strategy_mock(last_report: int, total_assets: int) -> MagicMock: + """Build a mock V3 tokenized strategy contract for the remote side.""" + contract = MagicMock() + contract.functions.lastReport.return_value.call.return_value = last_report + contract.functions.totalAssets.return_value.call.return_value = total_assets + return contract + + class TestYvUsdCctpChecks(unittest.TestCase): @patch("yearn.yvusd.send_alert") @patch("yearn.yvusd.ChainManager.get_client") @@ -20,15 +33,9 @@ def test_alerts_on_report_skew_between_local_and_remote( 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_strategy = _make_remote_strategy_mock(remote_last_report, 100_000_000) remote_client = MagicMock() - remote_client.eth.contract.return_value = remote_vault + remote_client.eth.contract.return_value = remote_strategy mock_get_client.return_value = remote_client mainnet_vault = MagicMock() @@ -39,7 +46,7 @@ def test_alerts_on_report_skew_between_local_and_remote( client.execute_batch.return_value = [(1, local_last_report, 100_000_000, 0)] api_data = { - "0x696d02Db93291651ED510704c9b286841d506987": { + YVUSD_VAULT: { "meta": { "strategies": [ { @@ -49,7 +56,6 @@ def test_alerts_on_report_skew_between_local_and_remote( "type": "cross-chain", "remote_chain_id": Chain.ARBITRUM.chain_id, "remote_vault": "0x78b7774c4368df8f2c115Abf6210F557753a6aC5", - "remote_counterpart": "0xaDa882B1BcB9B658b354ade0cE64586A88cb6849", }, } ] @@ -71,15 +77,9 @@ def test_alerts_on_remote_staleness(self, mock_get_client: MagicMock, mock_send_ 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_strategy = _make_remote_strategy_mock(now - stale_seconds, 200_000_000) remote_client = MagicMock() - remote_client.eth.contract.return_value = remote_vault + remote_client.eth.contract.return_value = remote_strategy mock_get_client.return_value = remote_client client = MagicMock() @@ -89,7 +89,7 @@ def test_alerts_on_remote_staleness(self, mock_get_client: MagicMock, mock_send_ client.execute_batch.return_value = [(1, now - 3600, 100_000_000, 0)] api_data = { - "0x696d02Db93291651ED510704c9b286841d506987": { + YVUSD_VAULT: { "meta": { "strategies": [ { @@ -99,7 +99,6 @@ def test_alerts_on_remote_staleness(self, mock_get_client: MagicMock, mock_send_ "type": "cross-chain", "remote_chain_id": Chain.ARBITRUM.chain_id, "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", - "remote_counterpart": "0xAA442539f43d9A864e26e56E5C8Ee791E9Df7dA2", }, } ] @@ -113,6 +112,176 @@ def test_alerts_on_remote_staleness(self, mock_get_client: MagicMock, mock_send_ mock_send_alert.assert_called_once() self.assertIn("report stale", mock_send_alert.call_args.args[0].message) + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_alerts_when_remote_lookup_fails(self, mock_get_client: MagicMock, mock_send_alert: MagicMock): + """Failure to read remote state must surface an alert, not silently skip.""" + now = 1_000_000 + + remote_strategy = MagicMock() + remote_strategy.functions.lastReport.return_value.call.side_effect = RuntimeError("execution reverted") + remote_client = MagicMock() + remote_client.eth.contract.return_value = remote_strategy + 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 = { + YVUSD_VAULT: { + "meta": { + "strategies": [ + { + "address": "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", + "meta": { + "name": "Arbitrum syrupUSDC/USDC Morpho Looper", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", + }, + } + ] + } + } + } + + 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("Remote Lookup Failed", message) + self.assertIn("Arbitrum syrupUSDC/USDC Morpho Looper", message) + + +class TestYvUsdLooperPositionCollection(unittest.TestCase): + def test_includes_cross_chain_loopers_with_remote_morpho_market(self): + """Cross-chain wrappers whose remote side is a looper must be covered.""" + strategies = [ + { + "address": "0xMainnetLooper", + "debt": "5000000000000", + "meta": { + "name": "Mainnet Direct Looper", + "type": "morpho-looper", + "market_id": "0xaaaa", + }, + }, + { + "address": "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", + "debt": "100404831974", + "meta": { + "name": "Arbitrum syrupUSDC/USDC Morpho Looper", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", + "remote_vault_type": "morpho-looper", + "remote_meta": { + "type": "morpho-looper", + "market_id": "0xf86f3edd6f16cd8211f4d206866dc4ecd41be6211063ac11f8508e1b7112ef40", + }, + }, + }, + { + "address": "0xCrossChainNonLooper", + "debt": "1000", + "meta": { + "name": "Cross-chain default vault", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0x000000000000000000000000000000000000dead", + "remote_vault_type": "default", + "remote_meta": {"type": "default"}, + }, + }, + { + "address": "0xZeroDebtLooper", + "debt": "0", + "meta": {"name": "Zero debt", "type": "morpho-looper", "market_id": "0xbbbb"}, + }, + ] + + positions = _collect_looper_positions(strategies) + + self.assertEqual(len(positions), 2) + mainnet = next(p for p in positions if p.chain == Chain.MAINNET) + cross = next(p for p in positions if p.chain == Chain.ARBITRUM) + + self.assertEqual(mainnet.borrower, "0xMainnetLooper") + self.assertEqual(mainnet.market_id, "0xaaaa") + + # Borrower for cross-chain is the remote tokenized strategy, not the mainnet wrapper. + self.assertEqual(cross.borrower, "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453") + self.assertEqual(cross.mainnet_strategy, "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179") + self.assertEqual(cross.market_id, "0xf86f3edd6f16cd8211f4d206866dc4ecd41be6211063ac11f8508e1b7112ef40") + + +class TestYvUsdFlashloanLiquidity(unittest.TestCase): + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_alerts_on_insufficient_liquidity_for_cross_chain_looper( + self, mock_get_client: MagicMock, mock_send_alert: MagicMock + ): + # Borrow shares == borrow assets when total_borrow_shares == total_borrow_assets + # market: total_supply=10M USDC, total_borrow=9M USDC, shares match -> liquidity = 1M + # position: borrow shares 50M USDC -> way more than 1M market liquidity and 100k Balancer + market = ( + 10_000_000 * 10**6, # totalSupplyAssets + 10_000_000 * 10**6, # totalSupplyShares + 9_000_000 * 10**6, # totalBorrowAssets + 9_000_000 * 10**6, # totalBorrowShares + 0, # lastUpdate + 0, # fee + ) + position = (0, 50_000_000 * 10**6, 0) # supplyShares, borrowShares, collateral + balancer_balance = 100_000 * 10**6 + + arb_client = MagicMock() + arb_client.batch_requests.return_value.__enter__.return_value = MagicMock() + arb_client.batch_requests.return_value.__exit__.return_value = False + arb_client.execute_batch.return_value = [market, position, balancer_balance] + mock_get_client.return_value = arb_client + + # Verify the chain we care about is configured + self.assertIn(Chain.ARBITRUM, LOOPER_CHAIN_CONFIG) + + api_data = { + YVUSD_VAULT: { + "meta": { + "strategies": [ + { + "address": "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", + "debt": "50000000000000", + "meta": { + "name": "Arbitrum syrupUSDC/USDC Morpho Looper", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", + "remote_vault_type": "morpho-looper", + "remote_meta": { + "market_id": "0xf86f3edd6f16cd8211f4d206866dc4ecd41be6211063ac11f8508e1b7112ef40", + }, + }, + } + ] + } + } + } + + check_flashloan_liquidity(api_data) + + mock_get_client.assert_called_once_with(Chain.ARBITRUM) + mock_send_alert.assert_called_once() + message = mock_send_alert.call_args.args[0].message + self.assertIn("Flashloan Liquidity Warning", message) + self.assertIn("arbitrum", message.lower()) + # Both the mainnet strategy link and the remote borrower link should appear + self.assertIn("0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", message) + self.assertIn("0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", message) + class TestYvUsdCooldownScanning(unittest.TestCase): @patch("yearn.yvusd.set_cache_value") @@ -129,6 +298,35 @@ def test_does_not_advance_cache_when_log_fetch_fails(self, mock_get_cache: Magic mock_set_cache.assert_not_called() + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=100) + def test_uses_snake_case_kwargs_for_get_logs(self, mock_get_cache: MagicMock, mock_set_cache: MagicMock): + """web3 7.x's get_logs takes from_block/to_block, not fromBlock/toBlock.""" + client = MagicMock() + client.eth.block_number = 250 + + locked = MagicMock() + locked.events.CooldownStarted.get_logs.return_value = [] + client.eth.contract.return_value = locked + + check_large_cooldowns(client) + + # web3 7.x raises TypeError on camelCase kwargs; assert we used snake_case. + kwargs = locked.events.CooldownStarted.get_logs.call_args.kwargs + self.assertIn("from_block", kwargs) + self.assertIn("to_block", kwargs) + self.assertNotIn("fromBlock", kwargs) + self.assertNotIn("toBlock", kwargs) + + +class TestLooperPositionDataclass(unittest.TestCase): + def test_dataclass_is_hashable_for_dedup(self): + """LooperPosition is frozen and should be usable as a dict key.""" + a = LooperPosition(Chain.MAINNET, "0xaa", "0xbb", "name", "0xbb") + b = LooperPosition(Chain.MAINNET, "0xaa", "0xbb", "name", "0xbb") + self.assertEqual(a, b) + self.assertEqual(hash(a), hash(b)) + if __name__ == "__main__": unittest.main() diff --git a/yearn/yvusd.py b/yearn/yvusd.py index 371e2995..7671f0c7 100644 --- a/yearn/yvusd.py +++ b/yearn/yvusd.py @@ -4,11 +4,12 @@ Monitors: - APY anomalies: unlocked APY > locked APY inversion, negative strategy APR - CCTP bridging delays: stale or out-of-sync cross-chain strategy reports -- Flashloan liquidity: available liquidity for looper strategy unwinding +- Flashloan liquidity: available liquidity for looper strategy unwinding (mainnet + cross-chain) - Large cooldown requests: significant LockedyvUSD cooldown events """ import time +from dataclasses import dataclass from utils.abi import load_abi from utils.alert import Alert, AlertSeverity, send_alert @@ -26,15 +27,26 @@ # --- ABIs --- ABI_VAULT = load_abi("yearn/abi/YearnV3Vault.json") -ABI_MORPHO = load_abi("morpho/abi/morpho.json") +ABI_MORPHO_BLUE = load_abi("morpho/abi/morpho_blue.json") ABI_LOCKED = load_abi("yearn/abi/LockedYvUSD.json") # --- Contract Addresses --- YVUSD_VAULT = "0x696d02Db93291651ED510704c9b286841d506987" LOCKED_YVUSD = "0xAaaFEa48472f77563961Cdb53291DEDfB46F9040" -MORPHO = "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb" -USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" -BALANCER_VAULT = "0xBA12222222228d8Ba445958a75a0704d566BF2C8" + +# Per-chain Morpho Blue + flashloan source addresses for looper unwinding checks. +LOOPER_CHAIN_CONFIG: dict[Chain, dict[str, str]] = { + Chain.MAINNET: { + "morpho": "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb", + "balancer_vault": "0xBA12222222228d8Ba445958a75a0704d566BF2C8", + "usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }, + Chain.ARBITRUM: { + "morpho": "0x6c247b1F6182318877311737BaC0844bAa518F5e", + "balancer_vault": "0xBA12222222228d8Ba445958a75a0704d566BF2C8", + "usdc": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + }, +} # --- API --- YVUSD_API_URL = "https://yvusd-api.yearn.fi/api/aprs" @@ -68,10 +80,42 @@ } ] +# Minimal V3 tokenized strategy ABI used for remote-side health checks. +# Remote "vaults" exposed by the cross-chain strategy metadata are actually V3 +# tokenized strategies (no strategies() mapping); they expose lastReport() and +# totalAssets() directly. +ABI_TOKENIZED_STRATEGY = [ + { + "type": "function", + "name": "lastReport", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + }, + { + "type": "function", + "name": "totalAssets", + "inputs": [], + "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") +@dataclass(frozen=True) +class LooperPosition: + """A Morpho-looper borrow position to monitor for flashloan unwind capacity.""" + + chain: Chain + market_id: str # 0x-prefixed hex + borrower: str # address of the contract holding the Morpho borrow + name: str # human-readable label + mainnet_strategy: str # mainnet yvUSD strategy that owns this position (for explorer link) + + 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) @@ -200,24 +244,27 @@ def check_strategy_staleness(client: Web3Client, api_data: dict) -> None: 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 or not remote_chain_id or not remote_vault or not remote_counterpart: + if activation == 0 or not remote_chain_id or not remote_vault: continue 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) + except ValueError: + logger.error("Unknown remote chain_id %s for strategy %s", remote_chain_id, name) + send_alert( + Alert( + AlertSeverity.MEDIUM, + f"yvUSD CCTP: unknown remote chain_id {remote_chain_id} for {name}", + PROTOCOL, + ) + ) continue - if remote_activation == 0: + remote_state = _fetch_remote_strategy_state(remote_chain, remote_vault, name) + if remote_state is None: continue + remote_last_report, remote_debt = remote_state local_hours_since = (now - local_last_report) / 3600 remote_hours_since = (now - remote_last_report) / 3600 @@ -253,6 +300,39 @@ def check_strategy_staleness(client: Web3Client, api_data: dict) -> None: send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) +def _fetch_remote_strategy_state(remote_chain: Chain, remote_vault: str, name: str) -> tuple[int, int] | None: + """Fetch (lastReport, totalAssets) for a remote V3 tokenized strategy. + + Returns None and surfaces a MEDIUM alert if the lookup fails — silent + skips would let the CCTP health check disable itself for misconfigured + strategies without anyone noticing. + """ + try: + remote_client = ChainManager.get_client(remote_chain) + remote_strategy = remote_client.eth.contract(address=remote_vault, abi=ABI_TOKENIZED_STRATEGY) + last_report = remote_strategy.functions.lastReport().call() + total_assets = remote_strategy.functions.totalAssets().call() + return int(last_report), int(total_assets) + except Exception as e: + explorer = remote_chain.explorer_url or "" + link = f"{explorer}/address/{remote_vault}" if explorer else remote_vault + logger.error("Failed to fetch remote state for %s on %s: %s", name, remote_chain.network_name, e) + send_alert( + Alert( + AlertSeverity.MEDIUM, + ( + f"*yvUSD CCTP Remote Lookup Failed*\n" + f"{name} on {remote_chain.network_name}\n" + f"Remote vault: {link}\n" + f"Error: {e}\n" + f"CCTP health check is unable to verify this strategy" + ), + PROTOCOL, + ) + ) + return None + + def _build_cctp_alert_lines( *, name: str, @@ -294,45 +374,156 @@ def _build_cctp_alert_lines( ] -def check_flashloan_liquidity(client: Web3Client, api_data: dict) -> None: +def _collect_looper_positions(strategies: list[dict]) -> list[LooperPosition]: + """Discover all active Morpho-looper borrow positions from the API metadata. + + Includes both: + - Direct mainnet looper strategies (type in LOOPER_STRATEGY_TYPES) + - Cross-chain wrappers where the remote side is itself a looper + (remote_vault_type in LOOPER_STRATEGY_TYPES); the borrower on Morpho is + the remote tokenized strategy (`remote_vault`). + """ + positions: list[LooperPosition] = [] + + for s in strategies: + meta = s.get("meta", {}) or {} + type_ = meta.get("type") + debt = int(s.get("debt", "0")) + name = meta.get("name", s.get("address", "unknown")) + address = s.get("address", "") + + if debt <= 0: + continue + + if type_ in LOOPER_STRATEGY_TYPES: + market_id = meta.get("market_id") + if not market_id: + continue + positions.append( + LooperPosition( + chain=Chain.MAINNET, + market_id=market_id, + borrower=address, + name=name, + mainnet_strategy=address, + ) + ) + continue + + if type_ != "cross-chain": + continue + + if meta.get("remote_vault_type") not in LOOPER_STRATEGY_TYPES: + continue + + remote_chain_id = meta.get("remote_chain_id") + remote_vault = meta.get("remote_vault") + remote_meta = meta.get("remote_meta") or {} + market_id = remote_meta.get("market_id") + + if not (remote_chain_id and remote_vault and market_id): + logger.warning("Cross-chain looper %s missing remote market metadata; skipping", name) + continue + + try: + remote_chain = Chain.from_chain_id(remote_chain_id) + except ValueError: + logger.error("Cross-chain looper %s on unknown chain_id %s; skipping", name, remote_chain_id) + send_alert( + Alert( + AlertSeverity.MEDIUM, + f"yvUSD: cross-chain looper {name} references unknown chain_id {remote_chain_id}", + PROTOCOL, + ) + ) + continue + + positions.append( + LooperPosition( + chain=remote_chain, + market_id=market_id, + borrower=remote_vault, + name=name, + mainnet_strategy=address, + ) + ) + + return positions + + +def check_flashloan_liquidity(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. + flashloan liquidity from the chain's Balancer vault and the Morpho market. + Cross-chain loopers are checked on their remote chain (where the actual + leverage and unwind liquidity live). """ 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 - ] + positions = _collect_looper_positions(strategies) - if not loopers: - logger.info("No active Morpho looper strategies found") + if not positions: + logger.info("No active Morpho looper positions found") return - morpho = client.eth.contract(address=MORPHO, abi=ABI_MORPHO) - usdc = client.eth.contract(address=USDC, abi=ABI_ERC20_BALANCE) + by_chain: dict[Chain, list[LooperPosition]] = {} + for p in positions: + by_chain.setdefault(p.chain, []).append(p) + + for chain, chain_positions in by_chain.items(): + config = LOOPER_CHAIN_CONFIG.get(chain) + if not config: + logger.error("No looper config for chain %s; %d positions uncovered", chain.name, len(chain_positions)) + send_alert( + Alert( + AlertSeverity.MEDIUM, + ( + f"yvUSD: flashloan liquidity check unsupported on {chain.network_name} " + f"({len(chain_positions)} looper position(s) uncovered)" + ), + PROTOCOL, + ) + ) + continue + + try: + _check_chain_flashloan_liquidity(chain, chain_positions, config) + except Exception as e: + logger.error("Flashloan liquidity check failed on %s: %s", chain.name, e) + send_alert( + Alert( + AlertSeverity.MEDIUM, + f"yvUSD flashloan liquidity check failed on {chain.network_name}: {e}", + PROTOCOL, + ) + ) + + +def _check_chain_flashloan_liquidity(chain: Chain, positions: list[LooperPosition], config: dict[str, str]) -> None: + """Run the flashloan liquidity check for all positions on a single chain.""" + client = ChainManager.get_client(chain) + morpho = client.eth.contract(address=config["morpho"], abi=ABI_MORPHO_BLUE) + usdc = client.eth.contract(address=config["usdc"], abi=ABI_ERC20_BALANCE) with client.batch_requests() as batch: - for strategy in loopers: - market_id = bytes.fromhex(strategy["meta"]["market_id"][2:]) + for p in positions: + market_id = bytes.fromhex(p.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)) + batch.add(morpho.functions.position(market_id, p.borrower)) + batch.add(usdc.functions.balanceOf(config["balancer_vault"])) responses = client.execute_batch(batch) - expected = len(loopers) * 2 + 1 + expected = len(positions) * 2 + 1 if len(responses) != expected: - logger.error("Unexpected batch response count for flashloan liquidity check") + logger.error("Unexpected batch response count on %s: got %d, expected %d", chain.name, len(responses), expected) return balancer_usdc = responses[-1] / ONE_USDC - logger.info("Balancer vault USDC balance: %s", format_usd(balancer_usdc)) + logger.info("[%s] Balancer vault USDC balance: %s", chain.name, format_usd(balancer_usdc)) + + explorer = chain.explorer_url or "https://etherscan.io" - for i, strategy in enumerate(loopers): + for i, p in enumerate(positions): market_data = responses[i * 2] position_data = responses[i * 2 + 1] @@ -349,12 +540,11 @@ def check_flashloan_liquidity(client: Web3Client, api_data: dict) -> None: 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, + "[%s] Looper %s — borrow: %s, market liquidity: %s", + chain.name, + p.name, format_usd(borrow_usd), format_usd(market_liquidity), ) @@ -365,14 +555,16 @@ def check_flashloan_liquidity(client: Web3Client, api_data: dict) -> None: # 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: + links = [f"[Strategy](https://etherscan.io/address/{p.mainnet_strategy})"] + if p.chain != Chain.MAINNET: + links.append(f"[Borrower on {p.chain.network_name}]({explorer}/address/{p.borrower})") message = ( f"*yvUSD Flashloan Liquidity Warning*\n" - f"{name}\n" + f"{p.name} ({p.chain.network_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})" + f"Insufficient flashloan liquidity for strategy unwinding\n" + " | ".join(links) ) send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) @@ -405,7 +597,7 @@ def check_large_cooldowns(client: Web3Client) -> None: 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) + events = locked.events.CooldownStarted.get_logs(from_block=from_block, to_block=current_block) except Exception as e: logger.warning("Could not fetch CooldownStarted events: %s", e) return @@ -444,7 +636,7 @@ def main() -> None: if api_data: check_apy_anomalies(api_data) check_strategy_staleness(client, api_data) - check_flashloan_liquidity(client, api_data) + check_flashloan_liquidity(api_data) else: send_alert(Alert(AlertSeverity.MEDIUM, "Failed to fetch yvUSD API data", PROTOCOL)) except Exception as e: From a230333e49fd325c501bb74a32f9e2e543273798 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 12 May 2026 20:50:50 +0200 Subject: [PATCH 7/8] fix(yvusd): debounce repeat alerts, guard missing remote RPCs, alert on cooldown RPC failure - Skip CCTP staleness + cross-chain flashloan checks when PROVIDER_URL_{CHAIN} is not set (one-shot MEDIUM alert per chain via dedup flag) so the live Katana yvUSDC Compounder ($2.22M) doesn't trip "Remote Lookup Failed" every hourly run when no Katana RPC is configured. - Surface MEDIUM alert when CooldownStarted get_logs fails so an extended RPC outage doesn't silently mute large-cooldown monitoring; flag clears automatically on next successful fetch. - Debounce negative-APR and flashloan-liquidity alerts per-strategy / per-(chain, borrower) to stop alert spam while a condition persists; flags clear on recovery so a re-occurrence will alert again. - Rename cache file from generic cache-id.txt to cache-yvusd.txt. - Use local_chain.network_name in CCTP alert summary instead of literal "Mainnet" for consistency with the remote-side line. Tests cover: RPC-gap one-shot alert + dedup, cooldown failure alert + dedup, negative-APR alert/dedup/recovery, flashloan alert/dedup. 17 tests, all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_yvusd.py | 267 +++++++++++++++++++++++++++++++++++++++++++- yearn/yvusd.py | 101 ++++++++++++++++- 2 files changed, 361 insertions(+), 7 deletions(-) diff --git a/tests/test_yvusd.py b/tests/test_yvusd.py index 6aa1bc02..5ae3eac7 100644 --- a/tests/test_yvusd.py +++ b/tests/test_yvusd.py @@ -3,11 +3,16 @@ from utils.chains import Chain from yearn.yvusd import ( + CACHE_KEY_COOLDOWN_FETCH_FAILED, + CACHE_KEY_FLASHLOAN_PREFIX, + CACHE_KEY_NEG_APR_PREFIX, + CACHE_KEY_RPC_MISSING_PREFIX, CCTP_REPORT_SKEW_HOURS, CCTP_REPORT_STALENESS_HOURS, LOOPER_CHAIN_CONFIG, YVUSD_VAULT, LooperPosition, + _check_negative_strategy_apr, _collect_looper_positions, check_flashloan_liquidity, check_large_cooldowns, @@ -23,11 +28,12 @@ def _make_remote_strategy_mock(last_report: int, total_assets: int) -> MagicMock return contract +@patch("yearn.yvusd._has_configured_rpc", return_value=True) 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 + self, mock_get_client: MagicMock, mock_send_alert: MagicMock, _mock_has_rpc: MagicMock ): now = 1_000_000 local_last_report = now - 3600 @@ -73,7 +79,9 @@ def test_alerts_on_report_skew_between_local_and_remote( @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): + def test_alerts_on_remote_staleness( + self, mock_get_client: MagicMock, mock_send_alert: MagicMock, _mock_has_rpc: MagicMock + ): now = 1_000_000 stale_seconds = int((CCTP_REPORT_STALENESS_HOURS + 1) * 3600) @@ -114,7 +122,9 @@ def test_alerts_on_remote_staleness(self, mock_get_client: MagicMock, mock_send_ @patch("yearn.yvusd.send_alert") @patch("yearn.yvusd.ChainManager.get_client") - def test_alerts_when_remote_lookup_fails(self, mock_get_client: MagicMock, mock_send_alert: MagicMock): + def test_alerts_when_remote_lookup_fails( + self, mock_get_client: MagicMock, mock_send_alert: MagicMock, _mock_has_rpc: MagicMock + ): """Failure to read remote state must surface an alert, not silently skip.""" now = 1_000_000 @@ -220,10 +230,18 @@ def test_includes_cross_chain_loopers_with_remote_morpho_market(self): class TestYvUsdFlashloanLiquidity(unittest.TestCase): + @patch("yearn.yvusd.get_cache_value", return_value=0) + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd._has_configured_rpc", return_value=True) @patch("yearn.yvusd.send_alert") @patch("yearn.yvusd.ChainManager.get_client") def test_alerts_on_insufficient_liquidity_for_cross_chain_looper( - self, mock_get_client: MagicMock, mock_send_alert: MagicMock + self, + mock_get_client: MagicMock, + mock_send_alert: MagicMock, + _mock_has_rpc: MagicMock, + _mock_set_cache: MagicMock, + _mock_get_cache: MagicMock, ): # Borrow shares == borrow assets when total_borrow_shares == total_borrow_assets # market: total_supply=10M USDC, total_borrow=9M USDC, shares match -> liquidity = 1M @@ -328,5 +346,246 @@ def test_dataclass_is_hashable_for_dedup(self): self.assertEqual(hash(a), hash(b)) +class TestYvUsdMissingRpcGuard(unittest.TestCase): + """Cross-chain strategies on chains without a configured PROVIDER_URL should + surface a single MEDIUM alert and skip — not spam a failure alert hourly.""" + + KATANA_STRATEGY = { + "address": "0xc5b16E7eFe1CA05714477b8edcAb4deE9b93a27C", + "debt": "2220302251405", + "meta": { + "name": "Katana yvUSDC Compounder", + "type": "cross-chain", + "remote_chain_id": Chain.KATANA.chain_id, + "remote_vault": "0x80c34BD3A3569E126e7055831036aa7b212cB159", + "remote_vault_type": "default", + }, + } + + def _client(self, local_last_report: int = 0): + 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, local_last_report, 2_220_302_251_405, 0)] + return client + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=0) + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + @patch("yearn.yvusd._has_configured_rpc", return_value=False) + def test_staleness_alerts_once_when_remote_rpc_missing( + self, + _mock_has_rpc: MagicMock, + mock_get_client: MagicMock, + mock_send_alert: MagicMock, + _mock_get_cache: MagicMock, + mock_set_cache: MagicMock, + ): + now = 1_000_000 + api_data = {YVUSD_VAULT: {"meta": {"strategies": [self.KATANA_STRATEGY]}}} + + with patch("yearn.yvusd.time.time", return_value=now): + check_strategy_staleness(self._client(local_last_report=now - 3600), api_data) + + # ChainManager.get_client must NOT have been called for the remote chain. + mock_get_client.assert_not_called() + + # One MEDIUM alert about the missing RPC. + mock_send_alert.assert_called_once() + alert = mock_send_alert.call_args.args[0] + self.assertIn("missing RPC", alert.message) + self.assertIn("katana", alert.message.lower()) + + # Dedup flag is written so subsequent runs stay silent. + mock_set_cache.assert_called_once() + cache_key = mock_set_cache.call_args.args[0] + self.assertEqual(cache_key, f"{CACHE_KEY_RPC_MISSING_PREFIX}KATANA") + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=1) # already-alerted flag set + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + @patch("yearn.yvusd._has_configured_rpc", return_value=False) + def test_staleness_does_not_realert_when_already_flagged( + self, + _mock_has_rpc: MagicMock, + mock_get_client: MagicMock, + mock_send_alert: MagicMock, + _mock_get_cache: MagicMock, + mock_set_cache: MagicMock, + ): + now = 1_000_000 + api_data = {YVUSD_VAULT: {"meta": {"strategies": [self.KATANA_STRATEGY]}}} + + with patch("yearn.yvusd.time.time", return_value=now): + check_strategy_staleness(self._client(local_last_report=now - 3600), api_data) + + mock_get_client.assert_not_called() + mock_send_alert.assert_not_called() + mock_set_cache.assert_not_called() + + +class TestYvUsdCooldownFailureAlert(unittest.TestCase): + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=0) + @patch("yearn.yvusd.send_alert") + def test_alerts_medium_on_first_get_logs_failure( + self, mock_send_alert: MagicMock, _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_send_alert.assert_called_once() + self.assertIn("Cooldown Scan Failed", mock_send_alert.call_args.args[0].message) + + # The failure flag is the only thing written; last-block must not advance. + cache_writes = {call.args[0] for call in mock_set_cache.call_args_list} + self.assertIn(CACHE_KEY_COOLDOWN_FETCH_FAILED, cache_writes) + self.assertNotIn("YVUSD_LAST_BLOCK", cache_writes) + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=1) # failure flag already set + @patch("yearn.yvusd.send_alert") + def test_does_not_realert_while_failure_flag_set( + self, mock_send_alert: MagicMock, _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("still failing") + client.eth.contract.return_value = locked + + check_large_cooldowns(client) + + mock_send_alert.assert_not_called() + + +class TestYvUsdNegativeAprDebounce(unittest.TestCase): + NEG_STRATEGY = { + "address": "0xAbCdEf0000000000000000000000000000000001", + "debt": "1000000000000", + "apr_raw": "-50000000000000000", + "meta": {"name": "Losing Money Strategy", "type": "default"}, + } + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=0) + @patch("yearn.yvusd.send_alert") + def test_alerts_once_then_sets_dedup_flag( + self, mock_send_alert: MagicMock, _mock_get_cache: MagicMock, mock_set_cache: MagicMock + ): + _check_negative_strategy_apr({"meta": {"strategies": [self.NEG_STRATEGY]}}) + + mock_send_alert.assert_called_once() + self.assertIn("Negative Strategy APR", mock_send_alert.call_args.args[0].message) + + expected_key = f"{CACHE_KEY_NEG_APR_PREFIX}{self.NEG_STRATEGY['address'].lower()}" + mock_set_cache.assert_called_once_with(expected_key, 1) + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=1) # already-alerted + @patch("yearn.yvusd.send_alert") + def test_skips_when_already_alerted( + self, mock_send_alert: MagicMock, _mock_get_cache: MagicMock, mock_set_cache: MagicMock + ): + _check_negative_strategy_apr({"meta": {"strategies": [self.NEG_STRATEGY]}}) + + mock_send_alert.assert_not_called() + mock_set_cache.assert_not_called() + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=1) + @patch("yearn.yvusd.send_alert") + def test_clears_flag_on_recovery( + self, mock_send_alert: MagicMock, _mock_get_cache: MagicMock, mock_set_cache: MagicMock + ): + recovered = {**self.NEG_STRATEGY, "apr_raw": "10000000000000000"} + _check_negative_strategy_apr({"meta": {"strategies": [recovered]}}) + + mock_send_alert.assert_not_called() + expected_key = f"{CACHE_KEY_NEG_APR_PREFIX}{recovered['address'].lower()}" + mock_set_cache.assert_called_once_with(expected_key, 0) + + +class TestYvUsdFlashloanDebounce(unittest.TestCase): + POSITION_STRATEGY = { + "address": "0x2F56D106C6Df739bdbb777C2feE79FFaED88D179", + "debt": "50000000000000", + "meta": { + "name": "Arbitrum syrupUSDC/USDC Morpho Looper", + "type": "cross-chain", + "remote_chain_id": Chain.ARBITRUM.chain_id, + "remote_vault": "0xBCf08997C34183d1b7B0f99e13aCeACFBA88E453", + "remote_vault_type": "morpho-looper", + "remote_meta": {"market_id": "0xf86f3edd6f16cd8211f4d206866dc4ecd41be6211063ac11f8508e1b7112ef40"}, + }, + } + + def _arb_client_with_shortfall(self) -> MagicMock: + # Same numbers as TestYvUsdFlashloanLiquidity: ~50M borrow vs 1M market + 100k Balancer. + market = (10_000_000 * 10**6, 10_000_000 * 10**6, 9_000_000 * 10**6, 9_000_000 * 10**6, 0, 0) + position = (0, 50_000_000 * 10**6, 0) + balancer_balance = 100_000 * 10**6 + client = MagicMock() + client.batch_requests.return_value.__enter__.return_value = MagicMock() + client.batch_requests.return_value.__exit__.return_value = False + client.execute_batch.return_value = [market, position, balancer_balance] + return client + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=1) # already-alerted flag set + @patch("yearn.yvusd._has_configured_rpc", return_value=True) + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_skips_when_flashloan_dedup_flag_set( + self, + mock_get_client: MagicMock, + mock_send_alert: MagicMock, + _mock_has_rpc: MagicMock, + _mock_get_cache: MagicMock, + mock_set_cache: MagicMock, + ): + mock_get_client.return_value = self._arb_client_with_shortfall() + api_data = {YVUSD_VAULT: {"meta": {"strategies": [self.POSITION_STRATEGY]}}} + + check_flashloan_liquidity(api_data) + + mock_send_alert.assert_not_called() + mock_set_cache.assert_not_called() + + @patch("yearn.yvusd.set_cache_value") + @patch("yearn.yvusd.get_cache_value", return_value=0) + @patch("yearn.yvusd._has_configured_rpc", return_value=True) + @patch("yearn.yvusd.send_alert") + @patch("yearn.yvusd.ChainManager.get_client") + def test_alerts_once_then_writes_flashloan_dedup_flag( + self, + mock_get_client: MagicMock, + mock_send_alert: MagicMock, + _mock_has_rpc: MagicMock, + _mock_get_cache: MagicMock, + mock_set_cache: MagicMock, + ): + mock_get_client.return_value = self._arb_client_with_shortfall() + api_data = {YVUSD_VAULT: {"meta": {"strategies": [self.POSITION_STRATEGY]}}} + + check_flashloan_liquidity(api_data) + + mock_send_alert.assert_called_once() + # Dedup flag is keyed by chain.name + lowercase borrower address. + expected_key = ( + f"{CACHE_KEY_FLASHLOAN_PREFIX}{Chain.ARBITRUM.name}_" + f"{self.POSITION_STRATEGY['meta']['remote_vault'].lower()}" + ) + mock_set_cache.assert_called_once_with(expected_key, 1) + + if __name__ == "__main__": unittest.main() diff --git a/yearn/yvusd.py b/yearn/yvusd.py index 7671f0c7..c95d3869 100644 --- a/yearn/yvusd.py +++ b/yearn/yvusd.py @@ -8,6 +8,7 @@ - Large cooldown requests: significant LockedyvUSD cooldown events """ +import os import time from dataclasses import dataclass @@ -23,7 +24,7 @@ PROTOCOL = "yearn" logger = get_logger("yvusd") -CACHE_FILENAME = "cache-id.txt" +CACHE_FILENAME = "cache-yvusd.txt" # --- ABIs --- ABI_VAULT = load_abi("yearn/abi/YearnV3Vault.json") @@ -64,6 +65,11 @@ 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" +CACHE_KEY_COOLDOWN_FETCH_FAILED = "YVUSD_COOLDOWN_FETCH_FAILED" +# Per-condition dedup keys are suffixed with a stable identifier (lowercased address or chain/borrower pair). +CACHE_KEY_NEG_APR_PREFIX = "YVUSD_NEG_APR_ALERTED_" +CACHE_KEY_FLASHLOAN_PREFIX = "YVUSD_FLASHLOAN_ALERTED_" +CACHE_KEY_RPC_MISSING_PREFIX = "YVUSD_RPC_MISSING_ALERTED_" # Number of blocks to scan per run (~1 hour at 12s/block) BLOCKS_PER_HOUR = 300 @@ -130,6 +136,14 @@ def set_cache_value(key: str, value: float) -> None: write_last_value_to_file(CACHE_FILENAME, key, value) +def _has_configured_rpc(chain: Chain) -> bool: + """Return True if at least one PROVIDER_URL_{CHAIN}[_N] env var is set.""" + base = f"PROVIDER_URL_{chain.name.upper()}" + if os.getenv(base): + return True + return any(os.getenv(f"{base}_{i}") for i in range(1, 4)) + + def check_apy_anomalies(api_data: dict) -> None: """Check for APY anomalies using the yvUSD API. @@ -188,7 +202,11 @@ def _check_apy_inversion(unlocked_apy: float, locked_apy: float) -> None: def _check_negative_strategy_apr(yvusd_data: dict) -> None: - """Alert if any active strategy has a negative APR.""" + """Alert if any active strategy has a negative APR. + + Debounced per-strategy so a sustained negative APR doesn't spam alerts every + hourly run. The dedup flag resets once the strategy returns to non-negative. + """ strategies = yvusd_data.get("meta", {}).get("strategies", []) for strategy in strategies: @@ -196,8 +214,12 @@ def _check_negative_strategy_apr(yvusd_data: dict) -> None: debt = int(strategy.get("debt", "0")) name = strategy.get("meta", {}).get("name", strategy.get("address", "unknown")) address = strategy.get("address", "unknown") + cache_key = f"{CACHE_KEY_NEG_APR_PREFIX}{address.lower()}" + already_alerted = get_cache_value(cache_key) == 1 if debt > 0 and apr_raw < 0: + if already_alerted: + continue apr_pct = apr_raw / 1e18 * 100 debt_usd = debt / ONE_USDC message = ( @@ -208,6 +230,11 @@ def _check_negative_strategy_apr(yvusd_data: dict) -> None: f"[Strategy](https://etherscan.io/address/{address})" ) send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + set_cache_value(cache_key, 1) + elif already_alerted: + # Recovered — clear the dedup flag so a new dip will alert again. + set_cache_value(cache_key, 0) + logger.info("Negative APR resolved for %s", name) def check_strategy_staleness(client: Web3Client, api_data: dict) -> None: @@ -261,6 +288,10 @@ def check_strategy_staleness(client: Web3Client, api_data: dict) -> None: ) continue + if not _has_configured_rpc(remote_chain): + _alert_missing_rpc_once(remote_chain, name, kind="CCTP staleness") + continue + remote_state = _fetch_remote_strategy_state(remote_chain, remote_vault, name) if remote_state is None: continue @@ -300,6 +331,34 @@ def check_strategy_staleness(client: Web3Client, api_data: dict) -> None: send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) +def _alert_missing_rpc_once(chain: Chain, name: str, *, kind: str) -> None: + """Send a one-shot MEDIUM alert when a remote chain has no PROVIDER_URL configured. + + Without this, ChainManager.get_client() would raise on every hourly run for + chains like Katana when PROVIDER_URL_KATANA isn't set, which would either + spam the failure path or be silently skipped depending on call site. + """ + cache_key = f"{CACHE_KEY_RPC_MISSING_PREFIX}{chain.name}" + if get_cache_value(cache_key) == 1: + logger.info("Skipping %s for %s on %s: no PROVIDER_URL configured (already alerted)", kind, name, chain.name) + return + + logger.warning("No PROVIDER_URL_%s configured; cannot run %s for %s", chain.name.upper(), kind, name) + send_alert( + Alert( + AlertSeverity.MEDIUM, + ( + f"*yvUSD: missing RPC for {chain.network_name}*\n" + f"PROVIDER_URL_{chain.name.upper()} is not set — {kind} cannot run " + f"for cross-chain strategies on {chain.network_name} (e.g. {name}).\n" + f"Configure the env var to re-enable this check." + ), + PROTOCOL, + ) + ) + set_cache_value(cache_key, 1) + + def _fetch_remote_strategy_state(remote_chain: Chain, remote_vault: str, name: str) -> tuple[int, int] | None: """Fetch (lastReport, totalAssets) for a remote V3 tokenized strategy. @@ -368,7 +427,7 @@ def _build_cctp_alert_lines( return [ name, *problems, - f"Mainnet last report: {local_hours_since:.1f}h ago, debt: {format_usd(local_debt / ONE_USDC)}", + f"{local_chain.network_name.title()} 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", ] @@ -486,6 +545,11 @@ def check_flashloan_liquidity(api_data: dict) -> None: ) continue + if chain != Chain.MAINNET and not _has_configured_rpc(chain): + names = ", ".join(p.name for p in chain_positions) + _alert_missing_rpc_once(chain, names, kind="flashloan liquidity check") + continue + try: _check_chain_flashloan_liquidity(chain, chain_positions, config) except Exception as e: @@ -549,12 +613,20 @@ def _check_chain_flashloan_liquidity(chain: Chain, positions: list[LooperPositio format_usd(market_liquidity), ) + # Debounce alerts per (chain, borrower) so a persistent shortfall doesn't refire hourly. + dedup_key = f"{CACHE_KEY_FLASHLOAN_PREFIX}{chain.name}_{p.borrower.lower()}" + already_alerted = get_cache_value(dedup_key) == 1 + if borrow_assets == 0: + if already_alerted: + set_cache_value(dedup_key, 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: + if already_alerted: + continue links = [f"[Strategy](https://etherscan.io/address/{p.mainnet_strategy})"] if p.chain != Chain.MAINNET: links.append(f"[Borrower on {p.chain.network_name}]({explorer}/address/{p.borrower})") @@ -567,6 +639,11 @@ def _check_chain_flashloan_liquidity(chain: Chain, positions: list[LooperPositio f"Insufficient flashloan liquidity for strategy unwinding\n" + " | ".join(links) ) send_alert(Alert(AlertSeverity.HIGH, message, PROTOCOL)) + set_cache_value(dedup_key, 1) + elif already_alerted: + # Recovered — clear the dedup flag so the next shortfall alerts again. + set_cache_value(dedup_key, 0) + logger.info("[%s] Flashloan liquidity recovered for %s", chain.name, p.name) def check_large_cooldowns(client: Web3Client) -> None: @@ -600,8 +677,26 @@ def check_large_cooldowns(client: Web3Client) -> None: events = locked.events.CooldownStarted.get_logs(from_block=from_block, to_block=current_block) except Exception as e: logger.warning("Could not fetch CooldownStarted events: %s", e) + if get_cache_value(CACHE_KEY_COOLDOWN_FETCH_FAILED) == 0: + send_alert( + Alert( + AlertSeverity.MEDIUM, + ( + f"*yvUSD Cooldown Scan Failed*\n" + f"Could not fetch CooldownStarted events: {e}\n" + f"Large cooldown monitoring is paused until RPC recovers; " + f"cache is not advanced so missed events will be picked up on recovery." + ), + PROTOCOL, + ) + ) + set_cache_value(CACHE_KEY_COOLDOWN_FETCH_FAILED, 1) return + # Successful fetch — clear any previously-set failure flag. + if get_cache_value(CACHE_KEY_COOLDOWN_FETCH_FAILED) == 1: + set_cache_value(CACHE_KEY_COOLDOWN_FETCH_FAILED, 0) + large_count = 0 for event in events: shares = event["args"]["shares"] From 909ce26f78c6b0297d70ce1e104e149c72ca3268 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 19 May 2026 10:52:47 +0000 Subject: [PATCH 8/8] fix(yvusd): persist state via shared cache-id.txt so hourly workflow restores it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hourly workflow caches only cache-id.txt. yvUSD was writing to cache-yvusd.txt, so the APY-inversion 6-hour timer, dedup flags, RPC-missing flag, cooldown-failure flag, and last scanned block would reset every run — the 6h threshold could never be reached and persistent conditions would re-alert hourly. Match the shared-cache convention used by maple, ustb, 3jane, timelock, morpho, and yearn/alert_large_flows (all run in the same hourly job). Co-Authored-By: Claude Opus 4.7 --- yearn/yvusd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yearn/yvusd.py b/yearn/yvusd.py index c95d3869..bbf35a4f 100644 --- a/yearn/yvusd.py +++ b/yearn/yvusd.py @@ -24,7 +24,7 @@ PROTOCOL = "yearn" logger = get_logger("yvusd") -CACHE_FILENAME = "cache-yvusd.txt" +CACHE_FILENAME = "cache-id.txt" # --- ABIs --- ABI_VAULT = load_abi("yearn/abi/YearnV3Vault.json")