From 7351bbc75a0cdeb3cb3f49c6d42284b21d9d4512 Mon Sep 17 00:00:00 2001 From: yvtapir Date: Mon, 24 Aug 2026 19:43:25 +0300 Subject: [PATCH 1/3] feat: monitor Yearn lender-borrower risk --- automation/jobs.yaml | 16 + monitoring.yaml | 8 +- protocols/yearn/README.md | 35 ++ protocols/yearn/abi/AprOracle.json | 9 + protocols/yearn/abi/LenderBorrower.json | 30 ++ protocols/yearn/abi/MorphoCore.json | 16 + protocols/yearn/abi/MorphoIrm.json | 33 ++ protocols/yearn/abi/MorphoOracle.json | 3 + protocols/yearn/lender_borrower.py | 592 ++++++++++++++++++++++++ tests/test_lender_borrower.py | 163 +++++++ 10 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 protocols/yearn/abi/AprOracle.json create mode 100644 protocols/yearn/abi/LenderBorrower.json create mode 100644 protocols/yearn/abi/MorphoCore.json create mode 100644 protocols/yearn/abi/MorphoIrm.json create mode 100644 protocols/yearn/abi/MorphoOracle.json create mode 100644 protocols/yearn/lender_borrower.py create mode 100644 tests/test_lender_borrower.py diff --git a/automation/jobs.yaml b/automation/jobs.yaml index bddbdff2..c6b8ca0b 100644 --- a/automation/jobs.yaml +++ b/automation/jobs.yaml @@ -17,6 +17,22 @@ profiles: + half_hourly: + cron: "*/30 * * * *" + description: "Thirty-minute risk monitoring for active Yearn strategies." + tasks: + - name: "yearn-lender-borrower-ltv" + script: protocols/yearn/lender_borrower.py + args: { checks: ltv } + + six_hourly: + cron: "17 */6 * * *" + description: "Six-hour rate and debt-coverage monitoring for active Yearn strategies." + tasks: + - name: "yearn-lender-borrower-rates-and-coverage" + script: protocols/yearn/lender_borrower.py + args: { checks: rates-and-coverage } + hourly: cron: "5 * * * *" description: "Hourly monitoring (was .github/workflows/hourly.yml)." diff --git a/monitoring.yaml b/monitoring.yaml index c71705c1..09a67552 100644 --- a/monitoring.yaml +++ b/monitoring.yaml @@ -455,15 +455,19 @@ protocols: yearn: display_name: "Yearn" - description: "Yearn vault flow and timelock monitoring" - cadence: "Hourly / Daily" + description: "Yearn vault, strategy risk, and timelock monitoring" + cadence: "Every 30 minutes / 6 hours / Hourly / Daily" tasks: + - protocols/yearn/lender_borrower.py - protocols/yearn/alert_large_flows.py - protocols/yearn/check_timelock_delay.py - protocols/yearn/check_indexer_freshness.py monitors: - name: "Indexer Freshness" description: "Envio indexer lag per chain; alerts the Envio channel when a chain's newest indexed block is older than 60 minutes or the GraphQL endpoint is down" + - name: "Lender-Borrower Risk" + description: "Katana Morpho vbWBTC/yvUSDC: LTV every 30m; 24h net spread and debt coverage every 6h" + severity: "MEDIUM" - name: "Large Flows" description: "Deposit/withdrawal flows >=$500k USD (Katana withdrawals >=$50k; or 10% of vault totalSupply fallback for unpriced tokens)" - name: "Timelock Delay" diff --git a/protocols/yearn/README.md b/protocols/yearn/README.md index ea777084..e2ec3bfd 100644 --- a/protocols/yearn/README.md +++ b/protocols/yearn/README.md @@ -2,6 +2,41 @@ This folder contains monitoring scripts for Yearn vault activity, Safe multisig queues, and timelock operations. +## Lender-Borrower Risk + +The script `yearn/lender_borrower.py` monitors the active Katana Morpho +`vbWBTC/yvUSDC` lender-borrower strategy. The strategy deposits vbWBTC as +Morpho collateral, borrows vbUSDC, and lends the borrowed vbUSDC into the Yearn +vbUSDC vault. + +### Checks + +1. **Liquidation risk**: reproduces the strategy warning LTV from Morpho's LLTV + and `warningLTVMultiplier()`, then alerts when `getCurrentLTV()` exceeds it. + The displayed vbWBTC and vbUSDC prices come from the strategy's Morpho and + USD oracles. Runs every 30 minutes. +2. **Net spread**: calculates Morpho's current borrow APR from its IRM and + subtracts it from the lender vault APR returned by Yearn's APR oracle. A + medium alert fires after at least four samples when the rolling 24-hour + average is below `-1%`. Runs every six hours. +3. **Debt coverage**: compares `balanceOfLentAssets() + + balanceOfBorrowToken()` with `balanceOfDebt()`. A medium alert fires when the + deficit is both at least 10 basis points of debt and worth at least $100. + Runs every six hours with the net-spread check. + +All breach and monitor-error alerts use `MEDIUM` severity. Persistent breaches +are deduplicated and reminded once per 24 hours. The monitor is read-only and +does not initiate deleveraging. + +### Usage + +```bash +uv run protocols/yearn/lender_borrower.py --checks=ltv --dry-run +uv run protocols/yearn/lender_borrower.py --checks=rates-and-coverage --dry-run +``` + +Omit `--dry-run` to persist rate samples and send configured alerts. + ## Large Flows The script `yearn/alert_large_flows.py` checks recent deposit and withdrawal events and sends a Telegram alert when a single flow exceeds a USD threshold. It runs hourly via the [monitoring runner](../automation/jobs.yaml). diff --git a/protocols/yearn/abi/AprOracle.json b/protocols/yearn/abi/AprOracle.json new file mode 100644 index 00000000..a9317b5e --- /dev/null +++ b/protocols/yearn/abi/AprOracle.json @@ -0,0 +1,9 @@ +[ + { + "inputs": [{"name": "strategy", "type": "address"}, {"name": "delta", "type": "int256"}], + "name": "getStrategyApr", + "outputs": [{"name": "apr", "type": "uint256"}], + "stateMutability": "view", + "type": "function" + } +] diff --git a/protocols/yearn/abi/LenderBorrower.json b/protocols/yearn/abi/LenderBorrower.json new file mode 100644 index 00000000..11914e52 --- /dev/null +++ b/protocols/yearn/abi/LenderBorrower.json @@ -0,0 +1,30 @@ +[ + {"inputs": [], "name": "name", "outputs": [{"type": "string"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "asset", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "borrowToken", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "lenderVault", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "morpho", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "marketId", "outputs": [{"type": "bytes32"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "borrowUsdOracle", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}, + { + "inputs": [], + "name": "marketParams", + "outputs": [ + {"name": "loanToken", "type": "address"}, + {"name": "collateralToken", "type": "address"}, + {"name": "oracle", "type": "address"}, + {"name": "irm", "type": "address"}, + {"name": "lltv", "type": "uint256"} + ], + "stateMutability": "view", + "type": "function" + }, + {"inputs": [], "name": "balanceOfCollateral", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "balanceOfDebt", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "balanceOfLentAssets", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "balanceOfBorrowToken", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "getCurrentLTV", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "getLiquidateCollateralFactor", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "warningLTVMultiplier", "outputs": [{"type": "uint16"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "targetLTVMultiplier", "outputs": [{"type": "uint16"}], "stateMutability": "view", "type": "function"} +] diff --git a/protocols/yearn/abi/MorphoCore.json b/protocols/yearn/abi/MorphoCore.json new file mode 100644 index 00000000..0f2635a3 --- /dev/null +++ b/protocols/yearn/abi/MorphoCore.json @@ -0,0 +1,16 @@ +[ + { + "inputs": [{"name": "id", "type": "bytes32"}], + "name": "market", + "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", + "type": "function" + } +] diff --git a/protocols/yearn/abi/MorphoIrm.json b/protocols/yearn/abi/MorphoIrm.json new file mode 100644 index 00000000..ad54849c --- /dev/null +++ b/protocols/yearn/abi/MorphoIrm.json @@ -0,0 +1,33 @@ +[ + { + "inputs": [ + { + "name": "marketParams", + "type": "tuple", + "components": [ + {"name": "loanToken", "type": "address"}, + {"name": "collateralToken", "type": "address"}, + {"name": "oracle", "type": "address"}, + {"name": "irm", "type": "address"}, + {"name": "lltv", "type": "uint256"} + ] + }, + { + "name": "market", + "type": "tuple", + "components": [ + {"name": "totalSupplyAssets", "type": "uint128"}, + {"name": "totalSupplyShares", "type": "uint128"}, + {"name": "totalBorrowAssets", "type": "uint128"}, + {"name": "totalBorrowShares", "type": "uint128"}, + {"name": "lastUpdate", "type": "uint128"}, + {"name": "fee", "type": "uint128"} + ] + } + ], + "name": "borrowRateView", + "outputs": [{"name": "rate", "type": "uint256"}], + "stateMutability": "view", + "type": "function" + } +] diff --git a/protocols/yearn/abi/MorphoOracle.json b/protocols/yearn/abi/MorphoOracle.json new file mode 100644 index 00000000..f7458800 --- /dev/null +++ b/protocols/yearn/abi/MorphoOracle.json @@ -0,0 +1,3 @@ +[ + {"inputs": [], "name": "price", "outputs": [{"type": "uint256"}], "stateMutability": "view", "type": "function"} +] diff --git a/protocols/yearn/lender_borrower.py b/protocols/yearn/lender_borrower.py new file mode 100644 index 00000000..eb54a502 --- /dev/null +++ b/protocols/yearn/lender_borrower.py @@ -0,0 +1,592 @@ +#!/usr/bin/env python3 +"""Monitor Yearn lender-borrower strategy liquidation and spread risk.""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from web3 import Web3 + +from utils import store +from utils.abi import load_abi +from utils.alert import Alert, AlertSeverity, send_alert +from utils.chains import Chain +from utils.logger import get_logger +from utils.web3_wrapper import ChainManager, Web3Client + +PROTOCOL = "yearn" +logger = get_logger("yearn.lender_borrower") + +WAD = 10**18 +USD_SCALE = 10**8 +MAX_BPS = 10_000 +ORACLE_PRICE_SCALE = 10**36 +SECONDS_PER_YEAR = 31_556_952 +VIRTUAL_SHARES = 10**6 +VIRTUAL_ASSETS = 1 + +YEARN_APR_ORACLE = "0x1981AD9F44F2EA9aDd2dC4AD7D075c102C70aF92" +RATE_STATE_NAMESPACE = "yearn_lender_borrower_rates" +ALERT_STATE_NAMESPACE = "yearn_lender_borrower_alerts" +ALERT_REMINDER_SECONDS = 24 * 60 * 60 +CHECK_LTV = "ltv" +CHECK_RATES_AND_COVERAGE = "rates-and-coverage" +CHECK_ALL = "all" + +STRATEGY_ABI = load_abi("protocols/yearn/abi/LenderBorrower.json") +MORPHO_ABI = load_abi("protocols/yearn/abi/MorphoCore.json") +IRM_ABI = load_abi("protocols/yearn/abi/MorphoIrm.json") +MORPHO_ORACLE_ABI = load_abi("protocols/yearn/abi/MorphoOracle.json") +APR_ORACLE_ABI = load_abi("protocols/yearn/abi/AprOracle.json") +ERC20_ABI = load_abi("common-abi/ERC20.json") +CHAINLINK_ABI = load_abi("common-abi/ChainlinkAggregator.json") + + +@dataclass(frozen=True) +class StrategyConfig: + """Static monitoring policy for one lender-borrower strategy.""" + + name: str + chain: Chain + address: str + joc_url: str + negative_spread_threshold_bps: int = 100 + rate_window_hours: int = 24 + minimum_rate_samples: int = 4 + deficit_threshold_bps: int = 10 + deficit_min_usd: int = 100 + + +@dataclass(frozen=True) +class RateSample: + """One observed lender-minus-borrow APR sample.""" + + timestamp: int + spread_wad: int + + +@dataclass(frozen=True) +class StrategySnapshot: + """On-chain state used by all lender-borrower checks.""" + + timestamp: int + strategy_name: str + collateral_symbol: str + collateral_decimals: int + borrow_symbol: str + borrow_decimals: int + collateral: int + debt: int + lent: int + idle_borrow_token: int + current_ltv_wad: int + target_ltv_wad: int + warning_ltv_wad: int + liquidation_ltv_wad: int + collateral_price_usd_e8: int + borrow_price_usd_e8: int + lender_apr_wad: int | None + borrow_apr_wad: int | None + + @property + def available_borrow_token(self) -> int: + """Return lent plus idle borrow tokens.""" + return self.lent + self.idle_borrow_token + + @property + def deficit(self) -> int: + """Return uncovered debt in borrow-token units.""" + return max(0, self.debt - self.available_borrow_token) + + @property + def deficit_bps(self) -> int: + """Return uncovered debt as integer basis points of debt.""" + return self.deficit * MAX_BPS // self.debt if self.debt else 0 + + @property + def deficit_usd_e8(self) -> int: + """Return uncovered debt value in 1e8 USD units.""" + return int(self.deficit * self.borrow_price_usd_e8 // (10**self.borrow_decimals)) + + @property + def spread_wad(self) -> int: + """Return lender APR minus borrow APR in WAD.""" + if self.lender_apr_wad is None or self.borrow_apr_wad is None: + raise ValueError("Rate data was not loaded for this snapshot") + return self.lender_apr_wad - self.borrow_apr_wad + + +@dataclass(frozen=True) +class Evaluation: + """Policy result for one strategy snapshot.""" + + issue_codes: tuple[str, ...] + issue_messages: tuple[str, ...] + average_spread_wad: int | None + rate_sample_count: int + + +STRATEGIES = ( + StrategyConfig( + name="Morpho vbWBTC/yvUSDC Lender Borrower", + chain=Chain.KATANA, + address="0x0432337365d89c0D73f1D0Cb263791F8f1B98D43", + joc_url="https://joc.yearn.dev/strategy/katana/0x0432337365d89c0D73f1D0Cb263791F8f1B98D43", + ), +) + + +def calculate_warning_ltv(liquidation_ltv_wad: int, warning_multiplier_bps: int) -> int: + """Reproduce BaseLenderBorrower._getWarningLTV().""" + return liquidation_ltv_wad * warning_multiplier_bps // MAX_BPS + + +def calculate_target_ltv(liquidation_ltv_wad: int, target_multiplier_bps: int) -> int: + """Reproduce BaseLenderBorrower._getTargetLTV().""" + return liquidation_ltv_wad * target_multiplier_bps // MAX_BPS + + +def taylor_compounded(rate_per_second_wad: int, elapsed_seconds: int) -> int: + """Reproduce Morpho's three-term compounded-interest approximation.""" + first_term = rate_per_second_wad * elapsed_seconds + second_term = first_term * first_term // (2 * WAD) + third_term = second_term * first_term // (3 * WAD) + return first_term + second_term + third_term + + +def accrue_market(market: tuple[int, int, int, int, int, int], rate_per_second_wad: int, now: int) -> tuple[int, ...]: + """Return Morpho market balances after expected interest accrual.""" + total_supply_assets, total_supply_shares, total_borrow_assets, total_borrow_shares, last_update, fee = market + elapsed = max(0, now - last_update) + if elapsed == 0 or total_borrow_assets == 0: + return market + + interest = total_borrow_assets * taylor_compounded(rate_per_second_wad, elapsed) // WAD + total_borrow_assets += interest + total_supply_assets += interest + + if fee: + fee_amount = interest * fee // WAD + fee_shares = ( + fee_amount * (total_supply_shares + VIRTUAL_SHARES) // (total_supply_assets - fee_amount + VIRTUAL_ASSETS) + ) + total_supply_shares += fee_shares + + return ( + total_supply_assets, + total_supply_shares, + total_borrow_assets, + total_borrow_shares, + last_update, + fee, + ) + + +def prune_rate_samples(samples: list[RateSample], now: int, window_hours: int) -> list[RateSample]: + """Keep unique samples within the configured rolling window.""" + cutoff = now - window_hours * 60 * 60 + by_timestamp = {sample.timestamp: sample for sample in samples if cutoff <= sample.timestamp <= now} + return sorted(by_timestamp.values(), key=lambda sample: sample.timestamp) + + +def average_spread(samples: list[RateSample], minimum_samples: int) -> int | None: + """Return the arithmetic mean spread once enough observations exist.""" + if len(samples) < minimum_samples: + return None + return sum(sample.spread_wad for sample in samples) // len(samples) + + +def evaluate_snapshot( + config: StrategyConfig, + snapshot: StrategySnapshot, + rate_samples: list[RateSample], + checks: str = CHECK_ALL, +) -> Evaluation: + """Evaluate LTV, rolling spread, and debt-coverage policy.""" + issue_codes: list[str] = [] + issue_messages: list[str] = [] + + if checks in (CHECK_LTV, CHECK_ALL): + if snapshot.debt > 0 and snapshot.collateral == 0: + issue_codes.append("debt_without_collateral") + issue_messages.append("Debt is non-zero while collateral is zero") + elif snapshot.current_ltv_wad > snapshot.warning_ltv_wad: + issue_codes.append("ltv") + issue_messages.append( + f"LTV {_format_percent(snapshot.current_ltv_wad)} is above warning " + f"{_format_percent(snapshot.warning_ltv_wad)}" + ) + + if checks in (CHECK_RATES_AND_COVERAGE, CHECK_ALL): + deficit_threshold_usd_e8 = config.deficit_min_usd * USD_SCALE + if snapshot.deficit_bps >= config.deficit_threshold_bps and snapshot.deficit_usd_e8 >= deficit_threshold_usd_e8: + issue_codes.append("debt_coverage") + issue_messages.append( + f"Debt deficit is {_format_token(snapshot.deficit, snapshot.borrow_decimals)} " + f"{snapshot.borrow_symbol} ({snapshot.deficit_bps} bps)" + ) + + avg_spread_wad = None + if checks in (CHECK_RATES_AND_COVERAGE, CHECK_ALL): + avg_spread_wad = average_spread(rate_samples, config.minimum_rate_samples) if snapshot.debt else None + threshold_wad = config.negative_spread_threshold_bps * WAD // MAX_BPS + if avg_spread_wad is not None and avg_spread_wad < -threshold_wad: + issue_codes.append("net_spread") + issue_messages.append( + f"{config.rate_window_hours}h average net spread {_format_percent(avg_spread_wad)} is below " + f"-{config.negative_spread_threshold_bps / 100:.2f}%" + ) + + return Evaluation(tuple(issue_codes), tuple(issue_messages), avg_spread_wad, len(rate_samples)) + + +def _as_address(value: Any) -> str: + return str(Web3.to_checksum_address(str(value))) + + +def _read_snapshot(config: StrategyConfig, *, include_rates: bool) -> StrategySnapshot: + client = ChainManager.get_client(config.chain) + strategy_address = Web3.to_checksum_address(config.address) + strategy = client.get_contract(strategy_address, STRATEGY_ABI) + + with client.batch_requests() as batch: + for call in ( + strategy.functions.name(), + strategy.functions.asset(), + strategy.functions.borrowToken(), + strategy.functions.lenderVault(), + strategy.functions.morpho(), + strategy.functions.marketId(), + strategy.functions.borrowUsdOracle(), + strategy.functions.marketParams(), + strategy.functions.balanceOfCollateral(), + strategy.functions.balanceOfDebt(), + strategy.functions.balanceOfLentAssets(), + strategy.functions.balanceOfBorrowToken(), + strategy.functions.getCurrentLTV(), + strategy.functions.getLiquidateCollateralFactor(), + strategy.functions.warningLTVMultiplier(), + strategy.functions.targetLTVMultiplier(), + ): + batch.add(call) + values = client.execute_batch(batch) + + ( + strategy_name, + collateral_address, + borrow_address, + lender_vault_address, + morpho_address, + market_id, + borrow_usd_oracle_address, + market_params_raw, + collateral, + debt, + lent, + idle_borrow_token, + current_ltv_wad, + liquidation_ltv_wad, + warning_multiplier_bps, + target_multiplier_bps, + ) = values + + collateral_address = _as_address(collateral_address) + borrow_address = _as_address(borrow_address) + lender_vault_address = _as_address(lender_vault_address) + morpho_address = _as_address(morpho_address) + borrow_usd_oracle_address = _as_address(borrow_usd_oracle_address) + market_params = ( + _as_address(market_params_raw[0]), + _as_address(market_params_raw[1]), + _as_address(market_params_raw[2]), + _as_address(market_params_raw[3]), + int(market_params_raw[4]), + ) + if market_params[0] != borrow_address or market_params[1] != collateral_address: + raise ValueError("Strategy token getters do not match Morpho market params") + if int(liquidation_ltv_wad) != market_params[4]: + raise ValueError("Strategy liquidation factor does not match Morpho LLTV") + + block = client.execute(client.eth.get_block, "latest") + block_timestamp = int(block["timestamp"]) + collateral_token = client.get_contract(collateral_address, ERC20_ABI) + borrow_token = client.get_contract(borrow_address, ERC20_ABI) + morpho_oracle = client.get_contract(market_params[2], MORPHO_ORACLE_ABI) + price_feed = client.get_contract(borrow_usd_oracle_address, CHAINLINK_ABI) + + with client.batch_requests() as batch: + for call in ( + collateral_token.functions.symbol(), + collateral_token.functions.decimals(), + borrow_token.functions.symbol(), + borrow_token.functions.decimals(), + morpho_oracle.functions.price(), + price_feed.functions.decimals(), + price_feed.functions.latestRoundData(), + ): + batch.add(call) + aux = client.execute_batch(batch) + + ( + collateral_symbol, + collateral_decimals, + borrow_symbol, + borrow_decimals, + oracle_price, + price_feed_decimals, + price_round, + ) = aux + borrow_price_answer = int(price_round[1]) + if borrow_price_answer <= 0: + raise ValueError("Borrow-token USD oracle returned an invalid price") + + borrow_price_usd_e8 = borrow_price_answer * USD_SCALE // (10 ** int(price_feed_decimals)) + collateral_price_borrow_wad = ( + int(oracle_price) + * (10 ** int(collateral_decimals)) + * WAD + // (ORACLE_PRICE_SCALE * (10 ** int(borrow_decimals))) + ) + collateral_price_usd_e8 = collateral_price_borrow_wad * borrow_price_usd_e8 // WAD + + lender_apr_wad: int | None = None + borrow_apr_wad: int | None = None + if include_rates: + morpho = client.get_contract(morpho_address, MORPHO_ABI) + apr_oracle = client.get_contract(Web3.to_checksum_address(YEARN_APR_ORACLE), APR_ORACLE_ABI) + with client.batch_requests() as batch: + batch.add(morpho.functions.market(market_id)) + batch.add(apr_oracle.functions.getStrategyApr(lender_vault_address, 0)) + market_raw, lender_apr_raw = client.execute_batch(batch) + + market_values = tuple(int(value) for value in market_raw) + if len(market_values) != 6: + raise ValueError(f"Morpho market returned {len(market_values)} values instead of 6") + market = ( + market_values[0], + market_values[1], + market_values[2], + market_values[3], + market_values[4], + market_values[5], + ) + irm = client.get_contract(market_params[3], IRM_ABI) + stored_rate = _call_irm(client, irm, market_params, market) + expected_market = accrue_market(market, stored_rate, block_timestamp) + borrow_rate_per_second = _call_irm(client, irm, market_params, expected_market) + lender_apr_wad = int(lender_apr_raw) + borrow_apr_wad = borrow_rate_per_second * SECONDS_PER_YEAR + + return StrategySnapshot( + timestamp=block_timestamp, + strategy_name=str(strategy_name), + collateral_symbol=str(collateral_symbol), + collateral_decimals=int(collateral_decimals), + borrow_symbol=str(borrow_symbol), + borrow_decimals=int(borrow_decimals), + collateral=int(collateral), + debt=int(debt), + lent=int(lent), + idle_borrow_token=int(idle_borrow_token), + current_ltv_wad=int(current_ltv_wad), + target_ltv_wad=calculate_target_ltv(int(liquidation_ltv_wad), int(target_multiplier_bps)), + warning_ltv_wad=calculate_warning_ltv(int(liquidation_ltv_wad), int(warning_multiplier_bps)), + liquidation_ltv_wad=int(liquidation_ltv_wad), + collateral_price_usd_e8=collateral_price_usd_e8, + borrow_price_usd_e8=borrow_price_usd_e8, + lender_apr_wad=lender_apr_wad, + borrow_apr_wad=borrow_apr_wad, + ) + + +def _call_irm( + client: Web3Client, + irm: Any, + market_params: tuple[str, str, str, str, int], + market: tuple[int, ...], +) -> int: + """Call Morpho's view IRM with the supplied market state.""" + return int(client.execute(irm.functions.borrowRateView(market_params, market).call)) + + +def _load_rate_samples(config: StrategyConfig) -> list[RateSample]: + raw = store.state_get(RATE_STATE_NAMESPACE, config.address.lower()) + if not raw: + return [] + try: + decoded = json.loads(raw) + return [RateSample(timestamp=int(row["timestamp"]), spread_wad=int(row["spread_wad"])) for row in decoded] + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + logger.warning("Ignoring malformed rate history for %s", config.address) + return [] + + +def _update_rate_samples(config: StrategyConfig, snapshot: StrategySnapshot, *, persist: bool) -> list[RateSample]: + samples = _load_rate_samples(config) if persist else [] + if snapshot.debt: + samples.append(RateSample(snapshot.timestamp, snapshot.spread_wad)) + samples = prune_rate_samples(samples, snapshot.timestamp, config.rate_window_hours) + if persist: + store.state_set( + RATE_STATE_NAMESPACE, + config.address.lower(), + json.dumps([{"timestamp": sample.timestamp, "spread_wad": sample.spread_wad} for sample in samples]), + ) + return samples + + +def _alert_state_key(config: StrategyConfig, checks: str) -> str: + return f"{config.address.lower()}:{checks}" + + +def _should_send_alert(config: StrategyConfig, evaluation: Evaluation, now: int, checks: str = CHECK_ALL) -> bool: + key = _alert_state_key(config, checks) + raw = store.state_get(ALERT_STATE_NAMESPACE, key) + previous: dict[str, Any] = {} + if raw: + try: + previous = json.loads(raw) + except (TypeError, json.JSONDecodeError): + previous = {} + + if not evaluation.issue_codes: + if previous.get("fingerprint"): + store.state_set(ALERT_STATE_NAMESPACE, key, json.dumps({"fingerprint": "", "last_alert": now})) + return False + + fingerprint = "|".join(sorted(evaluation.issue_codes)) + last_alert = int(previous.get("last_alert", 0)) + return previous.get("fingerprint") != fingerprint or now - last_alert >= ALERT_REMINDER_SECONDS + + +def _record_alert_sent(config: StrategyConfig, evaluation: Evaluation, now: int, checks: str = CHECK_ALL) -> None: + """Persist alert state only after Telegram delivery succeeds.""" + fingerprint = "|".join(sorted(evaluation.issue_codes)) + store.state_set( + ALERT_STATE_NAMESPACE, + _alert_state_key(config, checks), + json.dumps({"fingerprint": fingerprint, "last_alert": now}), + ) + + +def _format_percent(wad_value: int) -> str: + return f"{Decimal(wad_value) * 100 / WAD:.2f}%" + + +def _format_token(raw_value: int, decimals: int) -> str: + value = Decimal(raw_value) / (10**decimals) + if abs(value) >= 1_000: + return f"{value:,.2f}" + return f"{value:,.4f}" + + +def _format_usd(e8_value: int) -> str: + return f"${Decimal(e8_value) / USD_SCALE:,.2f}" + + +def _format_deficit_bps(snapshot: StrategySnapshot) -> str: + if snapshot.debt == 0: + return "0.00" + return f"{Decimal(snapshot.deficit) * MAX_BPS / snapshot.debt:.2f}" + + +def build_summary(config: StrategyConfig, snapshot: StrategySnapshot, evaluation: Evaluation, checks: str) -> str: + """Build the complete diagnostic summary used in logs and alerts.""" + lines = [config.name] + if checks in (CHECK_LTV, CHECK_ALL): + lines.extend( + [ + f"LTV: {_format_percent(snapshot.current_ltv_wad)} " + f"(target {_format_percent(snapshot.target_ltv_wad)}, warning {_format_percent(snapshot.warning_ltv_wad)}, " + f"liquidation {_format_percent(snapshot.liquidation_ltv_wad)})", + f"Prices: {snapshot.collateral_symbol} {_format_usd(snapshot.collateral_price_usd_e8)}, " + f"{snapshot.borrow_symbol} {_format_usd(snapshot.borrow_price_usd_e8)}", + ] + ) + if checks in (CHECK_RATES_AND_COVERAGE, CHECK_ALL): + if snapshot.lender_apr_wad is None or snapshot.borrow_apr_wad is None: + raise ValueError("Rate summary requested without rate data") + average_spread = ( + _format_percent(evaluation.average_spread_wad) + if evaluation.average_spread_wad is not None + else "warming up" + ) + lines.extend( + [ + f"Debt: {_format_token(snapshot.debt, snapshot.borrow_decimals)} {snapshot.borrow_symbol}", + f"Lent + idle: {_format_token(snapshot.available_borrow_token, snapshot.borrow_decimals)} " + f"{snapshot.borrow_symbol}", + f"Deficit: {_format_token(snapshot.deficit, snapshot.borrow_decimals)} {snapshot.borrow_symbol} " + f"({_format_deficit_bps(snapshot)} bps, {_format_usd(snapshot.deficit_usd_e8)})", + f"Rates: lender {_format_percent(snapshot.lender_apr_wad)}, " + f"borrow {_format_percent(snapshot.borrow_apr_wad)}, " + f"instant spread {_format_percent(snapshot.spread_wad)}, " + f"{config.rate_window_hours}h average {average_spread} " + f"({evaluation.rate_sample_count}/{config.minimum_rate_samples} minimum samples)", + ] + ) + lines.append(config.joc_url) + return "\n".join(lines) + + +def run_strategy(config: StrategyConfig, *, checks: str = CHECK_ALL, dry_run: bool = False) -> None: + """Read, evaluate, persist, and possibly alert for one strategy.""" + include_rates = checks in (CHECK_RATES_AND_COVERAGE, CHECK_ALL) + snapshot = _read_snapshot(config, include_rates=include_rates) + samples = _update_rate_samples(config, snapshot, persist=not dry_run) if include_rates else [] + evaluation = evaluate_snapshot(config, snapshot, samples, checks) + summary = build_summary(config, snapshot, evaluation, checks) + logger.info("%s", summary.replace("\n", " | ")) + + if not evaluation.issue_codes: + if not dry_run: + _should_send_alert(config, evaluation, snapshot.timestamp, checks) + return + + message = "Lender Borrower Warning\n" + "\n".join(f"- {issue}" for issue in evaluation.issue_messages) + message += "\n\n" + summary + if dry_run: + logger.warning("Dry run would alert: %s", message.replace("\n", " | ")) + elif _should_send_alert(config, evaluation, snapshot.timestamp, checks): + send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL), plain_text=True) + _record_alert_sent(config, evaluation, snapshot.timestamp, checks) + + +def main() -> None: + """Run the configured Yearn lender-borrower monitors.""" + parser = argparse.ArgumentParser(description="Monitor Yearn lender-borrower strategies") + parser.add_argument( + "--checks", + choices=(CHECK_LTV, CHECK_RATES_AND_COVERAGE, CHECK_ALL), + default=CHECK_ALL, + help="Check group to run", + ) + parser.add_argument("--dry-run", action="store_true", help="Read and evaluate without storing state or alerting") + args = parser.parse_args() + + for config in STRATEGIES: + try: + run_strategy(config, checks=args.checks, dry_run=args.dry_run) + except Exception as exc: # noqa: BLE001 - isolate configured strategies from one another + logger.exception("Failed to evaluate %s", config.name) + if args.dry_run: + raise + send_alert( + Alert( + AlertSeverity.MEDIUM, + f"Lender Borrower Monitor Error ({args.checks})\n" + f"{config.name}\n{type(exc).__name__}: {exc}\n{config.joc_url}", + PROTOCOL, + ), + plain_text=True, + ) + + +if __name__ == "__main__": + from utils.runner import run_with_alert + + run_with_alert(main, PROTOCOL) diff --git a/tests/test_lender_borrower.py b/tests/test_lender_borrower.py new file mode 100644 index 00000000..16c6a8e4 --- /dev/null +++ b/tests/test_lender_borrower.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from dataclasses import replace +from typing import Any + +from protocols.yearn.lender_borrower import ( + CHECK_LTV, + CHECK_RATES_AND_COVERAGE, + MAX_BPS, + STRATEGIES, + WAD, + Evaluation, + RateSample, + StrategySnapshot, + _record_alert_sent, + _should_send_alert, + accrue_market, + average_spread, + calculate_warning_ltv, + evaluate_snapshot, + prune_rate_samples, +) + +CONFIG = STRATEGIES[0] + + +def _snapshot(**changes: Any) -> StrategySnapshot: + base = StrategySnapshot( + timestamp=1_000_000, + strategy_name=CONFIG.name, + collateral_symbol="vbWBTC", + collateral_decimals=8, + borrow_symbol="vbUSDC", + borrow_decimals=6, + collateral=15 * 10**8, + debt=600_000 * 10**6, + lent=599_900 * 10**6, + idle_borrow_token=100 * 10**6, + current_ltv_wad=58 * WAD // 100, + target_ltv_wad=602 * WAD // 1000, + warning_ltv_wad=688 * WAD // 1000, + liquidation_ltv_wad=86 * WAD // 100, + collateral_price_usd_e8=80_000 * 10**8, + borrow_price_usd_e8=10**8, + lender_apr_wad=3 * WAD // 100, + borrow_apr_wad=35 * WAD // 1000, + ) + return replace(base, **changes) + + +def test_calculate_warning_ltv_matches_strategy_formula() -> None: + assert calculate_warning_ltv(86 * WAD // 100, 8_000) == 688 * WAD // 1_000 + + +def test_accrue_market_adds_expected_interest() -> None: + market = (2_000_000, 2_000_000, 1_000_000, 1_000_000, 100, 0) + accrued = accrue_market(market, 10**14, 200) + assert accrued[0] > market[0] + assert accrued[2] > market[2] + assert accrued[0] - market[0] == accrued[2] - market[2] + assert accrued[1] == market[1] + assert accrued[3] == market[3] + + +def test_rate_samples_are_pruned_and_deduplicated() -> None: + now = 100_000 + samples = [ + RateSample(now - 90_000, 1), + RateSample(now - 10, 2), + RateSample(now - 10, 3), + RateSample(now + 1, 4), + ] + assert prune_rate_samples(samples, now, 24) == [RateSample(now - 10, 3)] + + +def test_average_spread_requires_minimum_samples() -> None: + samples = [RateSample(i, -WAD // 100) for i in range(3)] + assert average_spread(samples, 4) is None + samples.append(RateSample(4, -2 * WAD // 100)) + assert average_spread(samples, 4) == -5 * WAD // 400 + + +def test_healthy_snapshot_has_no_issues() -> None: + snapshot = _snapshot() + samples = [RateSample(snapshot.timestamp - i, snapshot.spread_wad) for i in range(4)] + evaluation = evaluate_snapshot(CONFIG, snapshot, samples) + assert evaluation.issue_codes == () + + +def test_ltv_above_warning_alerts() -> None: + snapshot = _snapshot(current_ltv_wad=69 * WAD // 100) + evaluation = evaluate_snapshot(CONFIG, snapshot, []) + assert "ltv" in evaluation.issue_codes + + +def test_ltv_mode_does_not_evaluate_rates_or_coverage() -> None: + snapshot = _snapshot( + current_ltv_wad=69 * WAD // 100, + lent=0, + idle_borrow_token=0, + lender_apr_wad=None, + borrow_apr_wad=None, + ) + evaluation = evaluate_snapshot(CONFIG, snapshot, [], CHECK_LTV) + assert evaluation.issue_codes == ("ltv",) + + +def test_debt_coverage_requires_relative_and_absolute_thresholds() -> None: + debt = 600_000 * 10**6 + # $500 is above the absolute threshold but below 10 bps of $600k debt. + below_relative = _snapshot(debt=debt, lent=debt - 500 * 10**6, idle_borrow_token=0) + assert "debt_coverage" not in evaluate_snapshot(CONFIG, below_relative, []).issue_codes + + at_both = _snapshot(debt=debt, lent=debt - 600 * 10**6, idle_borrow_token=0) + assert at_both.deficit_bps == 10 + assert "debt_coverage" in evaluate_snapshot(CONFIG, at_both, []).issue_codes + + +def test_negative_average_spread_alerts_only_after_four_samples() -> None: + snapshot = _snapshot(lender_apr_wad=2 * WAD // 100, borrow_apr_wad=4 * WAD // 100) + samples = [RateSample(snapshot.timestamp - i, snapshot.spread_wad) for i in range(3)] + assert "net_spread" not in evaluate_snapshot(CONFIG, snapshot, samples).issue_codes + + samples.append(RateSample(snapshot.timestamp - 4, snapshot.spread_wad)) + evaluation = evaluate_snapshot(CONFIG, snapshot, samples) + assert evaluation.average_spread_wad == -2 * WAD // 100 + assert "net_spread" in evaluation.issue_codes + + +def test_rates_and_coverage_mode_does_not_evaluate_ltv() -> None: + debt = 600_000 * 10**6 + snapshot = _snapshot( + current_ltv_wad=69 * WAD // 100, + debt=debt, + lent=debt - 600 * 10**6, + idle_borrow_token=0, + ) + evaluation = evaluate_snapshot(CONFIG, snapshot, [], CHECK_RATES_AND_COVERAGE) + assert evaluation.issue_codes == ("debt_coverage",) + + +def test_deficit_bps_uses_debt_denominator() -> None: + snapshot = _snapshot(debt=100_000, lent=99_900, idle_borrow_token=0) + assert snapshot.deficit_bps == 10 + assert snapshot.deficit * MAX_BPS // snapshot.debt == 10 + + +def test_alerts_are_deduplicated_and_reminded_daily() -> None: + now = 1_000_000 + evaluation = Evaluation(("ltv",), ("LTV is high",), None, 0) + assert _should_send_alert(CONFIG, evaluation, now) + + _record_alert_sent(CONFIG, evaluation, now) + assert not _should_send_alert(CONFIG, evaluation, now + 60) + assert _should_send_alert(CONFIG, evaluation, now + 24 * 60 * 60) + + +def test_new_issue_fingerprint_alerts_immediately() -> None: + now = 1_000_000 + ltv = Evaluation(("ltv",), ("LTV is high",), None, 0) + coverage = Evaluation(("debt_coverage",), ("Coverage is low",), None, 0) + _record_alert_sent(CONFIG, ltv, now) + assert _should_send_alert(CONFIG, coverage, now + 60) From b88ce4881575f8d5321e10480ccccf567db7d7da Mon Sep 17 00:00:00 2001 From: yvtapir Date: Tue, 25 Aug 2026 01:39:51 +0300 Subject: [PATCH 2/3] fix: harden lender-borrower monitoring --- automation/jobs.yaml | 4 +- deploy/runbook.md | 7 +- protocols/yearn/README.md | 18 +-- protocols/yearn/lender_borrower.py | 183 +++++++++++++++++++++++++---- tests/test_lender_borrower.py | 50 +++++++- 5 files changed, 221 insertions(+), 41 deletions(-) diff --git a/automation/jobs.yaml b/automation/jobs.yaml index c6b8ca0b..b60f0f54 100644 --- a/automation/jobs.yaml +++ b/automation/jobs.yaml @@ -5,9 +5,9 @@ # - the yearn-monitor systemd unit, whose ExecStartPre renders this into a supercronic # crontab on start (see deploy/runbook.md). # -# Adding or removing a script: edit the relevant profile's `tasks:` and merge to main. The +# Adding or removing a task in an existing profile: edit its `tasks:` and merge to main. The # multisig profile's pre-run sync (sync_before_run, below) lands it on the box within -# ~10 min — no restart needed. Only a `cron:` cadence change requires sync + +# ~10 min — no restart needed. Adding/removing a profile or changing `cron:` requires sync + # `sudo systemctl restart monitoring` to re-render the crontab (see deploy/runbook.md). # # Each task invokes one script. CLI flags go under `args:` as `key: value` and are emitted as diff --git a/deploy/runbook.md b/deploy/runbook.md index 9c9a3c7d..02564c9b 100644 --- a/deploy/runbook.md +++ b/deploy/runbook.md @@ -71,7 +71,8 @@ uv run python -m automation run hourly --dry-run uv run python -m automation run hourly ``` -Available profiles: `hourly`, `daily`, `multisig` (see `automation/jobs.yaml`). +Available profiles: `half_hourly`, `six_hourly`, `hourly`, `daily`, `multisig` +(see `automation/jobs.yaml`). --- @@ -101,8 +102,8 @@ PR and the next ~10-min multisig tick syncs it in; no SSH needed.** auto-sync but stay inert until a restart, because they're read once at scheduler boot, not per-tick: -- a `cron:` *cadence* change in `jobs.yaml` (the crontab is rendered at unit - start by `ExecStartPre`), and +- adding/removing a profile or changing a profile's `cron:` *cadence* in + `jobs.yaml` (the crontab is rendered at unit start by `ExecStartPre`), and - a `pyproject.toml` / `uv.lock` change (the venv). For those, after the PR merges: diff --git a/protocols/yearn/README.md b/protocols/yearn/README.md index e2ec3bfd..4ac3703c 100644 --- a/protocols/yearn/README.md +++ b/protocols/yearn/README.md @@ -14,19 +14,23 @@ vbUSDC vault. 1. **Liquidation risk**: reproduces the strategy warning LTV from Morpho's LLTV and `warningLTVMultiplier()`, then alerts when `getCurrentLTV()` exceeds it. The displayed vbWBTC and vbUSDC prices come from the strategy's Morpho and - USD oracles. Runs every 30 minutes. -2. **Net spread**: calculates Morpho's current borrow APR from its IRM and + USD oracles. The borrow-token USD feed must have updated within 26 hours. + Runs every 30 minutes. +2. **Net spread**: derives Morpho's instantaneous borrow APR from the adaptive + IRM's window-average rate and subtracts it from the lender vault APR returned by Yearn's APR oracle. A - medium alert fires after at least four samples when the rolling 24-hour - average is below `-1%`. Runs every six hours. + medium alert fires after at least three samples when the rolling 24-hour + average is below `-1%`. A zero lender APR is treated as unavailable data, + alerts, and is not stored as a rate sample. Runs every six hours. 3. **Debt coverage**: compares `balanceOfLentAssets() + balanceOfBorrowToken()` with `balanceOfDebt()`. A medium alert fires when the deficit is both at least 10 basis points of debt and worth at least $100. Runs every six hours with the net-spread check. -All breach and monitor-error alerts use `MEDIUM` severity. Persistent breaches -are deduplicated and reminded once per 24 hours. The monitor is read-only and -does not initiate deleveraging. +All breach, unavailable-data, and monitor-error alerts use `MEDIUM` severity, +which sends Telegram without invoking the HIGH/CRITICAL emergency-dispatch +hook. Persistent breaches and errors are deduplicated and reminded once per 24 +hours. The monitor is read-only and does not initiate deleveraging. ### Usage diff --git a/protocols/yearn/lender_borrower.py b/protocols/yearn/lender_borrower.py index eb54a502..ddd84a27 100644 --- a/protocols/yearn/lender_borrower.py +++ b/protocols/yearn/lender_borrower.py @@ -5,8 +5,10 @@ import argparse import json +import time from dataclasses import dataclass from decimal import Decimal +from math import isqrt from typing import Any from web3 import Web3 @@ -14,6 +16,7 @@ from utils import store from utils.abi import load_abi from utils.alert import Alert, AlertSeverity, send_alert +from utils.chainlink import CHAINLINK_ABI, RoundData from utils.chains import Chain from utils.logger import get_logger from utils.web3_wrapper import ChainManager, Web3Client @@ -26,12 +29,18 @@ MAX_BPS = 10_000 ORACLE_PRICE_SCALE = 10**36 SECONDS_PER_YEAR = 31_556_952 +MORPHO_SECONDS_PER_YEAR = 365 * 24 * 60 * 60 +MORPHO_TARGET_UTILIZATION_WAD = 9 * WAD // 10 +MORPHO_CURVE_STEEPNESS_WAD = 4 * WAD +MORPHO_MIN_RATE_AT_TARGET = (WAD // 1_000) // MORPHO_SECONDS_PER_YEAR +MORPHO_MAX_RATE_AT_TARGET = (2 * WAD) // MORPHO_SECONDS_PER_YEAR VIRTUAL_SHARES = 10**6 VIRTUAL_ASSETS = 1 YEARN_APR_ORACLE = "0x1981AD9F44F2EA9aDd2dC4AD7D075c102C70aF92" RATE_STATE_NAMESPACE = "yearn_lender_borrower_rates" ALERT_STATE_NAMESPACE = "yearn_lender_borrower_alerts" +ERROR_STATE_NAMESPACE = "yearn_lender_borrower_errors" ALERT_REMINDER_SECONDS = 24 * 60 * 60 CHECK_LTV = "ltv" CHECK_RATES_AND_COVERAGE = "rates-and-coverage" @@ -43,7 +52,6 @@ MORPHO_ORACLE_ABI = load_abi("protocols/yearn/abi/MorphoOracle.json") APR_ORACLE_ABI = load_abi("protocols/yearn/abi/AprOracle.json") ERC20_ABI = load_abi("common-abi/ERC20.json") -CHAINLINK_ABI = load_abi("common-abi/ChainlinkAggregator.json") @dataclass(frozen=True) @@ -56,9 +64,10 @@ class StrategyConfig: joc_url: str negative_spread_threshold_bps: int = 100 rate_window_hours: int = 24 - minimum_rate_samples: int = 4 + minimum_rate_samples: int = 3 deficit_threshold_bps: int = 10 deficit_min_usd: int = 100 + borrow_price_max_age_seconds: int = 26 * 60 * 60 @dataclass(frozen=True) @@ -150,6 +159,73 @@ def calculate_target_ltv(liquidation_ltv_wad: int, target_multiplier_bps: int) - return liquidation_ltv_wad * target_multiplier_bps // MAX_BPS +def _divide_to_zero(numerator: int, denominator: int) -> int: + """Divide integers with Solidity's signed truncation toward zero.""" + if denominator == 0: + raise ZeroDivisionError("division by zero") + sign = -1 if (numerator < 0) != (denominator < 0) else 1 + return sign * (abs(numerator) // abs(denominator)) + + +def morpho_curve_coefficient_wad(market: tuple[int, ...]) -> int: + """Return AdaptiveCurveIRM's utilization coefficient in WAD.""" + total_supply_assets = int(market[0]) + total_borrow_assets = int(market[2]) + utilization = total_borrow_assets * WAD // total_supply_assets if total_supply_assets else 0 + utilization = min(utilization, WAD) + + if utilization > MORPHO_TARGET_UTILIZATION_WAD: + err = (utilization - MORPHO_TARGET_UTILIZATION_WAD) * WAD // (WAD - MORPHO_TARGET_UTILIZATION_WAD) + coefficient = MORPHO_CURVE_STEEPNESS_WAD - WAD + else: + err = _divide_to_zero( + (utilization - MORPHO_TARGET_UTILIZATION_WAD) * WAD, + MORPHO_TARGET_UTILIZATION_WAD, + ) + coefficient = WAD - WAD * WAD // MORPHO_CURVE_STEEPNESS_WAD + return WAD + _divide_to_zero(coefficient * err, WAD) + + +def calculate_instantaneous_borrow_rate( + average_rate_per_second: int, + start_rate_per_second: int, + market: tuple[int, ...], +) -> int: + """Invert AdaptiveCurveIRM's window average to obtain its current end rate. + + The IRM averages start, midpoint, and end target rates using the trapezoidal + rule. Since the curve is linear in the target rate, ``avg / start`` equals + ``((1 + adaptation_factor) / 2) ** 2`` until a target-rate bound is hit. + The final clamp mirrors the IRM's min/max target-rate bounds. + """ + if average_rate_per_second <= 0 or start_rate_per_second <= 0: + raise ValueError("Morpho IRM returned a non-positive borrow rate") + + root_wad = isqrt(average_rate_per_second * WAD**2 // start_rate_per_second) + adaptation_factor_wad = 2 * root_wad - WAD + if adaptation_factor_wad <= 0: + raise ValueError("Morpho IRM average cannot be inverted safely") + + inferred_end_rate = start_rate_per_second * adaptation_factor_wad**2 // WAD**2 + curve_coefficient = morpho_curve_coefficient_wad(market) + minimum_rate = MORPHO_MIN_RATE_AT_TARGET * curve_coefficient // WAD + maximum_rate = MORPHO_MAX_RATE_AT_TARGET * curve_coefficient // WAD + return min(max(inferred_end_rate, minimum_rate), maximum_rate) + + +def validate_borrow_price_round(round_data: RoundData, now: int, max_age_seconds: int) -> None: + """Reject invalid, future-dated, or stale borrow-token USD prices.""" + if round_data.answer <= 0: + raise ValueError("Borrow-token USD oracle returned an invalid price") + if round_data.updated_at <= 0: + raise ValueError("Borrow-token USD oracle returned no update timestamp") + if round_data.updated_at > now: + raise ValueError("Borrow-token USD oracle update timestamp is in the future") + age = now - round_data.updated_at + if age > max_age_seconds: + raise ValueError(f"Borrow-token USD oracle is stale: age={age}s maximum={max_age_seconds}s") + + def taylor_compounded(rate_per_second_wad: int, elapsed_seconds: int) -> int: """Reproduce Morpho's three-term compounded-interest approximation.""" first_term = rate_per_second_wad * elapsed_seconds @@ -232,6 +308,9 @@ def evaluate_snapshot( avg_spread_wad = None if checks in (CHECK_RATES_AND_COVERAGE, CHECK_ALL): + if snapshot.debt and (snapshot.lender_apr_wad is None or snapshot.borrow_apr_wad is None): + issue_codes.append("rate_data") + issue_messages.append("Current lender or borrow APR is unavailable; no rate sample was recorded") avg_spread_wad = average_spread(rate_samples, config.minimum_rate_samples) if snapshot.debt else None threshold_wad = config.negative_spread_threshold_bps * WAD // MAX_BPS if avg_spread_wad is not None and avg_spread_wad < -threshold_wad: @@ -312,6 +391,7 @@ def _read_snapshot(config: StrategyConfig, *, include_rates: bool) -> StrategySn raise ValueError("Strategy liquidation factor does not match Morpho LLTV") block = client.execute(client.eth.get_block, "latest") + block_number = int(block["number"]) block_timestamp = int(block["timestamp"]) collateral_token = client.get_contract(collateral_address, ERC20_ABI) borrow_token = client.get_contract(borrow_address, ERC20_ABI) @@ -340,9 +420,13 @@ def _read_snapshot(config: StrategyConfig, *, include_rates: bool) -> StrategySn price_feed_decimals, price_round, ) = aux - borrow_price_answer = int(price_round[1]) - if borrow_price_answer <= 0: - raise ValueError("Borrow-token USD oracle returned an invalid price") + borrow_price_round = RoundData.from_tuple(price_round) + validate_borrow_price_round( + borrow_price_round, + block_timestamp, + config.borrow_price_max_age_seconds, + ) + borrow_price_answer = borrow_price_round.answer borrow_price_usd_e8 = borrow_price_answer * USD_SCALE // (10 ** int(price_feed_decimals)) collateral_price_borrow_wad = ( @@ -375,10 +459,11 @@ def _read_snapshot(config: StrategyConfig, *, include_rates: bool) -> StrategySn market_values[5], ) irm = client.get_contract(market_params[3], IRM_ABI) - stored_rate = _call_irm(client, irm, market_params, market) - expected_market = accrue_market(market, stored_rate, block_timestamp) - borrow_rate_per_second = _call_irm(client, irm, market_params, expected_market) - lender_apr_wad = int(lender_apr_raw) + average_rate = _call_irm(client, irm, market_params, market, block_number) + current_timestamp_market = market[:4] + (block_timestamp, market[5]) + start_rate = _call_irm(client, irm, market_params, current_timestamp_market, block_number) + borrow_rate_per_second = calculate_instantaneous_borrow_rate(average_rate, start_rate, market) + lender_apr_wad = int(lender_apr_raw) or None borrow_apr_wad = borrow_rate_per_second * SECONDS_PER_YEAR return StrategySnapshot( @@ -408,9 +493,15 @@ def _call_irm( irm: Any, market_params: tuple[str, str, str, str, int], market: tuple[int, ...], + block_identifier: int, ) -> int: """Call Morpho's view IRM with the supplied market state.""" - return int(client.execute(irm.functions.borrowRateView(market_params, market).call)) + return int( + client.execute( + irm.functions.borrowRateView(market_params, market).call, + block_identifier=block_identifier, + ) + ) def _load_rate_samples(config: StrategyConfig) -> list[RateSample]: @@ -427,7 +518,7 @@ def _load_rate_samples(config: StrategyConfig) -> list[RateSample]: def _update_rate_samples(config: StrategyConfig, snapshot: StrategySnapshot, *, persist: bool) -> list[RateSample]: samples = _load_rate_samples(config) if persist else [] - if snapshot.debt: + if snapshot.debt and snapshot.lender_apr_wad is not None and snapshot.borrow_apr_wad is not None: samples.append(RateSample(snapshot.timestamp, snapshot.spread_wad)) samples = prune_rate_samples(samples, snapshot.timestamp, config.rate_window_hours) if persist: @@ -473,6 +564,37 @@ def _record_alert_sent(config: StrategyConfig, evaluation: Evaluation, now: int, ) +def _error_state_key(config: StrategyConfig, checks: str) -> str: + return f"{config.address.lower()}:{checks}" + + +def _should_send_error(config: StrategyConfig, checks: str, error_type: str, now: int) -> bool: + """Return whether a monitor error is new or due for its daily reminder.""" + raw = store.state_get(ERROR_STATE_NAMESPACE, _error_state_key(config, checks)) + previous: dict[str, Any] = {} + if raw: + try: + previous = json.loads(raw) + except (TypeError, json.JSONDecodeError): + previous = {} + last_alert = int(previous.get("last_alert", 0)) + return previous.get("fingerprint") != error_type or now - last_alert >= ALERT_REMINDER_SECONDS + + +def _record_error_sent(config: StrategyConfig, checks: str, error_type: str, now: int) -> None: + store.state_set( + ERROR_STATE_NAMESPACE, + _error_state_key(config, checks), + json.dumps({"fingerprint": error_type, "last_alert": now}), + ) + + +def _clear_error_state(config: StrategyConfig, checks: str, now: int) -> None: + key = _error_state_key(config, checks) + if store.state_get(ERROR_STATE_NAMESPACE, key): + store.state_set(ERROR_STATE_NAMESPACE, key, json.dumps({"fingerprint": "", "last_alert": now})) + + def _format_percent(wad_value: int) -> str: return f"{Decimal(wad_value) * 100 / WAD:.2f}%" @@ -508,13 +630,19 @@ def build_summary(config: StrategyConfig, snapshot: StrategySnapshot, evaluation ] ) if checks in (CHECK_RATES_AND_COVERAGE, CHECK_ALL): - if snapshot.lender_apr_wad is None or snapshot.borrow_apr_wad is None: - raise ValueError("Rate summary requested without rate data") average_spread = ( _format_percent(evaluation.average_spread_wad) if evaluation.average_spread_wad is not None else "warming up" ) + if snapshot.lender_apr_wad is None or snapshot.borrow_apr_wad is None: + current_rates = "Rates: unavailable; current sample skipped" + else: + current_rates = ( + f"Rates: lender {_format_percent(snapshot.lender_apr_wad)}, " + f"borrow {_format_percent(snapshot.borrow_apr_wad)}, " + f"instant spread {_format_percent(snapshot.spread_wad)}" + ) lines.extend( [ f"Debt: {_format_token(snapshot.debt, snapshot.borrow_decimals)} {snapshot.borrow_symbol}", @@ -522,10 +650,7 @@ def build_summary(config: StrategyConfig, snapshot: StrategySnapshot, evaluation f"{snapshot.borrow_symbol}", f"Deficit: {_format_token(snapshot.deficit, snapshot.borrow_decimals)} {snapshot.borrow_symbol} " f"({_format_deficit_bps(snapshot)} bps, {_format_usd(snapshot.deficit_usd_e8)})", - f"Rates: lender {_format_percent(snapshot.lender_apr_wad)}, " - f"borrow {_format_percent(snapshot.borrow_apr_wad)}, " - f"instant spread {_format_percent(snapshot.spread_wad)}, " - f"{config.rate_window_hours}h average {average_spread} " + f"{current_rates}, {config.rate_window_hours}h average {average_spread} " f"({evaluation.rate_sample_count}/{config.minimum_rate_samples} minimum samples)", ] ) @@ -541,6 +666,8 @@ def run_strategy(config: StrategyConfig, *, checks: str = CHECK_ALL, dry_run: bo evaluation = evaluate_snapshot(config, snapshot, samples, checks) summary = build_summary(config, snapshot, evaluation, checks) logger.info("%s", summary.replace("\n", " | ")) + if not dry_run: + _clear_error_state(config, checks, snapshot.timestamp) if not evaluation.issue_codes: if not dry_run: @@ -575,15 +702,19 @@ def main() -> None: logger.exception("Failed to evaluate %s", config.name) if args.dry_run: raise - send_alert( - Alert( - AlertSeverity.MEDIUM, - f"Lender Borrower Monitor Error ({args.checks})\n" - f"{config.name}\n{type(exc).__name__}: {exc}\n{config.joc_url}", - PROTOCOL, - ), - plain_text=True, - ) + now = int(time.time()) + error_type = type(exc).__name__ + if _should_send_error(config, args.checks, error_type, now): + send_alert( + Alert( + AlertSeverity.MEDIUM, + f"Lender Borrower Monitor Error ({args.checks})\n" + f"{config.name}\n{error_type}: {exc}\n{config.joc_url}", + PROTOCOL, + ), + plain_text=True, + ) + _record_error_sent(config, args.checks, error_type, now) if __name__ == "__main__": diff --git a/tests/test_lender_borrower.py b/tests/test_lender_borrower.py index 16c6a8e4..0460f05c 100644 --- a/tests/test_lender_borrower.py +++ b/tests/test_lender_borrower.py @@ -3,6 +3,8 @@ from dataclasses import replace from typing import Any +import pytest + from protocols.yearn.lender_borrower import ( CHECK_LTV, CHECK_RATES_AND_COVERAGE, @@ -13,13 +15,18 @@ RateSample, StrategySnapshot, _record_alert_sent, + _record_error_sent, _should_send_alert, + _should_send_error, accrue_market, average_spread, + calculate_instantaneous_borrow_rate, calculate_warning_ltv, evaluate_snapshot, prune_rate_samples, + validate_borrow_price_round, ) +from utils.chainlink import RoundData CONFIG = STRATEGIES[0] @@ -62,6 +69,27 @@ def test_accrue_market_adds_expected_interest() -> None: assert accrued[3] == market[3] +def test_instantaneous_borrow_rate_inverts_irm_window_average() -> None: + market = (10_000_000, 10_000_000, 9_500_000, 9_500_000, 100, 0) + start_rate = 1_000_000_000 + adaptation_factor_wad = 12 * WAD // 10 + average_rate = start_rate * (WAD + adaptation_factor_wad) ** 2 // (4 * WAD**2) + expected_end_rate = start_rate * adaptation_factor_wad**2 // WAD**2 + + actual = calculate_instantaneous_borrow_rate(average_rate, start_rate, market) + assert abs(actual - expected_end_rate) <= 2 + + +def test_borrow_price_round_rejects_stale_or_invalid_data() -> None: + now = 100_000 + validate_borrow_price_round(RoundData(1, 10**8, now - 10, now - 10, 1), now, 60) + + with pytest.raises(ValueError, match="invalid price"): + validate_borrow_price_round(RoundData(1, 0, now - 10, now - 10, 1), now, 60) + with pytest.raises(ValueError, match="stale"): + validate_borrow_price_round(RoundData(1, 10**8, now - 61, now - 61, 1), now, 60) + + def test_rate_samples_are_pruned_and_deduplicated() -> None: now = 100_000 samples = [ @@ -116,17 +144,23 @@ def test_debt_coverage_requires_relative_and_absolute_thresholds() -> None: assert "debt_coverage" in evaluate_snapshot(CONFIG, at_both, []).issue_codes -def test_negative_average_spread_alerts_only_after_four_samples() -> None: +def test_negative_average_spread_alerts_after_three_samples() -> None: snapshot = _snapshot(lender_apr_wad=2 * WAD // 100, borrow_apr_wad=4 * WAD // 100) - samples = [RateSample(snapshot.timestamp - i, snapshot.spread_wad) for i in range(3)] + samples = [RateSample(snapshot.timestamp - i, snapshot.spread_wad) for i in range(2)] assert "net_spread" not in evaluate_snapshot(CONFIG, snapshot, samples).issue_codes - samples.append(RateSample(snapshot.timestamp - 4, snapshot.spread_wad)) + samples.append(RateSample(snapshot.timestamp - 3, snapshot.spread_wad)) evaluation = evaluate_snapshot(CONFIG, snapshot, samples) assert evaluation.average_spread_wad == -2 * WAD // 100 assert "net_spread" in evaluation.issue_codes +def test_zero_lender_apr_is_reported_as_unavailable_rate_data() -> None: + snapshot = _snapshot(lender_apr_wad=None) + evaluation = evaluate_snapshot(CONFIG, snapshot, [], CHECK_RATES_AND_COVERAGE) + assert evaluation.issue_codes == ("rate_data",) + + def test_rates_and_coverage_mode_does_not_evaluate_ltv() -> None: debt = 600_000 * 10**6 snapshot = _snapshot( @@ -161,3 +195,13 @@ def test_new_issue_fingerprint_alerts_immediately() -> None: coverage = Evaluation(("debt_coverage",), ("Coverage is low",), None, 0) _record_alert_sent(CONFIG, ltv, now) assert _should_send_alert(CONFIG, coverage, now + 60) + + +def test_monitor_errors_are_deduplicated_and_reminded_daily() -> None: + now = 1_000_000 + assert _should_send_error(CONFIG, CHECK_LTV, "ValueError", now) + + _record_error_sent(CONFIG, CHECK_LTV, "ValueError", now) + assert not _should_send_error(CONFIG, CHECK_LTV, "ValueError", now + 60) + assert _should_send_error(CONFIG, CHECK_LTV, "ValueError", now + 24 * 60 * 60) + assert _should_send_error(CONFIG, CHECK_LTV, "TimeoutError", now + 60) From 440d3d1b6640ea7c05657af148fed22a1d9878ad Mon Sep 17 00:00:00 2001 From: yvtapir Date: Tue, 25 Aug 2026 17:23:51 +0300 Subject: [PATCH 3/3] fix: address lender-borrower review feedback --- protocols/yearn/README.md | 30 ++----- protocols/yearn/lender_borrower.py | 133 +++++++++++++++-------------- 2 files changed, 73 insertions(+), 90 deletions(-) diff --git a/protocols/yearn/README.md b/protocols/yearn/README.md index 4ac3703c..27a5d092 100644 --- a/protocols/yearn/README.md +++ b/protocols/yearn/README.md @@ -4,33 +4,15 @@ This folder contains monitoring scripts for Yearn vault activity, Safe multisig ## Lender-Borrower Risk -The script `yearn/lender_borrower.py` monitors the active Katana Morpho -`vbWBTC/yvUSDC` lender-borrower strategy. The strategy deposits vbWBTC as -Morpho collateral, borrows vbUSDC, and lends the borrowed vbUSDC into the Yearn -vbUSDC vault. +The script `yearn/lender_borrower.py` monitors the active Katana Morpho `vbWBTC/yvUSDC` lender-borrower strategy. The strategy deposits vbWBTC as Morpho collateral, borrows vbUSDC, and lends the borrowed vbUSDC into the Yearn vbUSDC vault. ### Checks -1. **Liquidation risk**: reproduces the strategy warning LTV from Morpho's LLTV - and `warningLTVMultiplier()`, then alerts when `getCurrentLTV()` exceeds it. - The displayed vbWBTC and vbUSDC prices come from the strategy's Morpho and - USD oracles. The borrow-token USD feed must have updated within 26 hours. - Runs every 30 minutes. -2. **Net spread**: derives Morpho's instantaneous borrow APR from the adaptive - IRM's window-average rate and - subtracts it from the lender vault APR returned by Yearn's APR oracle. A - medium alert fires after at least three samples when the rolling 24-hour - average is below `-1%`. A zero lender APR is treated as unavailable data, - alerts, and is not stored as a rate sample. Runs every six hours. -3. **Debt coverage**: compares `balanceOfLentAssets() + - balanceOfBorrowToken()` with `balanceOfDebt()`. A medium alert fires when the - deficit is both at least 10 basis points of debt and worth at least $100. - Runs every six hours with the net-spread check. - -All breach, unavailable-data, and monitor-error alerts use `MEDIUM` severity, -which sends Telegram without invoking the HIGH/CRITICAL emergency-dispatch -hook. Persistent breaches and errors are deduplicated and reminded once per 24 -hours. The monitor is read-only and does not initiate deleveraging. +1. **Liquidation risk**: reproduces the strategy warning LTV from Morpho's LLTV and `warningLTVMultiplier()`, then alerts when `getCurrentLTV()` exceeds it. The displayed vbWBTC and vbUSDC prices come from the strategy's Morpho and USD oracles. The borrow-token USD feed must have updated within 26 hours. Runs every 30 minutes. +2. **Net spread**: derives Morpho's instantaneous borrow APR from the adaptive IRM's window-average rate and subtracts it from the lender vault APR returned by Yearn's APR oracle. A medium alert fires after at least three samples when the rolling 24-hour average is below `-1%`. A zero lender APR is treated as unavailable data, alerts, and is not stored as a rate sample. Runs every six hours. +3. **Debt coverage**: compares `balanceOfLentAssets() + balanceOfBorrowToken()` with `balanceOfDebt()`. A medium alert fires when the deficit is both at least 10 basis points of debt and worth at least $100. Runs every six hours with the net-spread check. + +All breach, unavailable-data, and monitor-error alerts use `MEDIUM` severity and route to the internal curation Telegram channel, falling back to the Yearn channel when curation is not configured. MEDIUM sends Telegram without invoking the HIGH/CRITICAL emergency-dispatch hook. Persistent breaches and errors are deduplicated and reminded once per 24 hours. The monitor is read-only and does not initiate deleveraging. ### Usage diff --git a/protocols/yearn/lender_borrower.py b/protocols/yearn/lender_borrower.py index ddd84a27..c8f79e24 100644 --- a/protocols/yearn/lender_borrower.py +++ b/protocols/yearn/lender_borrower.py @@ -19,6 +19,7 @@ from utils.chainlink import CHAINLINK_ABI, RoundData from utils.chains import Chain from utils.logger import get_logger +from utils.telegram import CURATION_CHANNEL, resolve_channel from utils.web3_wrapper import ChainManager, Web3Client PROTOCOL = "yearn" @@ -51,7 +52,6 @@ IRM_ABI = load_abi("protocols/yearn/abi/MorphoIrm.json") MORPHO_ORACLE_ABI = load_abi("protocols/yearn/abi/MorphoOracle.json") APR_ORACLE_ABI = load_abi("protocols/yearn/abi/AprOracle.json") -ERC20_ABI = load_abi("common-abi/ERC20.json") @dataclass(frozen=True) @@ -61,6 +61,18 @@ class StrategyConfig: name: str chain: Chain address: str + collateral_address: str + collateral_symbol: str + collateral_decimals: int + borrow_address: str + borrow_symbol: str + borrow_decimals: int + lender_vault_address: str + morpho_address: str + market_id: str + morpho_oracle_address: str + morpho_irm_address: str + liquidation_ltv_wad: int joc_url: str negative_spread_threshold_bps: int = 100 rate_window_hours: int = 24 @@ -69,6 +81,17 @@ class StrategyConfig: deficit_min_usd: int = 100 borrow_price_max_age_seconds: int = 26 * 60 * 60 + @property + def market_params(self) -> tuple[str, str, str, str, int]: + """Return the immutable Morpho market parameters.""" + return ( + self.borrow_address, + self.collateral_address, + self.morpho_oracle_address, + self.morpho_irm_address, + self.liquidation_ltv_wad, + ) + @dataclass(frozen=True) class RateSample: @@ -144,6 +167,18 @@ class Evaluation: name="Morpho vbWBTC/yvUSDC Lender Borrower", chain=Chain.KATANA, address="0x0432337365d89c0D73f1D0Cb263791F8f1B98D43", + collateral_address="0x0913DA6Da4b42f538B445599b46Bb4622342Cf52", + collateral_symbol="vbWBTC", + collateral_decimals=8, + borrow_address="0x203A662b0BD271A6ed5a60EdFbd04bFce608FD36", + borrow_symbol="vbUSDC", + borrow_decimals=6, + lender_vault_address="0x80c34BD3A3569E126e7055831036aa7b212cB159", + morpho_address="0xD50F2DffFd62f94Ee4AEd9ca05C61d0753268aBc", + market_id="0xcd2dc555dced7422a3144a4126286675449019366f83e9717be7c2deb3daae3e", + morpho_oracle_address="0xB60F728BdcE5e3921C0E42c1a6F07A1313D0040e", + morpho_irm_address="0x4F708C0ae7deD3d74736594C2109C2E3c065B428", + liquidation_ltv_wad=860_000_000_000_000_000, joc_url="https://joc.yearn.dev/strategy/katana/0x0432337365d89c0D73f1D0Cb263791F8f1B98D43", ), ) @@ -323,31 +358,21 @@ def evaluate_snapshot( return Evaluation(tuple(issue_codes), tuple(issue_messages), avg_spread_wad, len(rate_samples)) -def _as_address(value: Any) -> str: - return str(Web3.to_checksum_address(str(value))) - - def _read_snapshot(config: StrategyConfig, *, include_rates: bool) -> StrategySnapshot: client = ChainManager.get_client(config.chain) strategy_address = Web3.to_checksum_address(config.address) strategy = client.get_contract(strategy_address, STRATEGY_ABI) + # Static addresses and Morpho market parameters live in StrategyConfig. The + # remaining values are position state or management-settable configuration. with client.batch_requests() as batch: for call in ( - strategy.functions.name(), - strategy.functions.asset(), - strategy.functions.borrowToken(), - strategy.functions.lenderVault(), - strategy.functions.morpho(), - strategy.functions.marketId(), strategy.functions.borrowUsdOracle(), - strategy.functions.marketParams(), strategy.functions.balanceOfCollateral(), strategy.functions.balanceOfDebt(), strategy.functions.balanceOfLentAssets(), strategy.functions.balanceOfBorrowToken(), strategy.functions.getCurrentLTV(), - strategy.functions.getLiquidateCollateralFactor(), strategy.functions.warningLTVMultiplier(), strategy.functions.targetLTVMultiplier(), ): @@ -355,55 +380,30 @@ def _read_snapshot(config: StrategyConfig, *, include_rates: bool) -> StrategySn values = client.execute_batch(batch) ( - strategy_name, - collateral_address, - borrow_address, - lender_vault_address, - morpho_address, - market_id, borrow_usd_oracle_address, - market_params_raw, collateral, debt, lent, idle_borrow_token, current_ltv_wad, - liquidation_ltv_wad, warning_multiplier_bps, target_multiplier_bps, ) = values - collateral_address = _as_address(collateral_address) - borrow_address = _as_address(borrow_address) - lender_vault_address = _as_address(lender_vault_address) - morpho_address = _as_address(morpho_address) - borrow_usd_oracle_address = _as_address(borrow_usd_oracle_address) - market_params = ( - _as_address(market_params_raw[0]), - _as_address(market_params_raw[1]), - _as_address(market_params_raw[2]), - _as_address(market_params_raw[3]), - int(market_params_raw[4]), - ) - if market_params[0] != borrow_address or market_params[1] != collateral_address: - raise ValueError("Strategy token getters do not match Morpho market params") - if int(liquidation_ltv_wad) != market_params[4]: - raise ValueError("Strategy liquidation factor does not match Morpho LLTV") + lender_vault_address = Web3.to_checksum_address(config.lender_vault_address) + morpho_address = Web3.to_checksum_address(config.morpho_address) + borrow_usd_oracle_address = Web3.to_checksum_address(borrow_usd_oracle_address) + market_params = config.market_params + liquidation_ltv_wad = market_params[4] block = client.execute(client.eth.get_block, "latest") block_number = int(block["number"]) block_timestamp = int(block["timestamp"]) - collateral_token = client.get_contract(collateral_address, ERC20_ABI) - borrow_token = client.get_contract(borrow_address, ERC20_ABI) morpho_oracle = client.get_contract(market_params[2], MORPHO_ORACLE_ABI) price_feed = client.get_contract(borrow_usd_oracle_address, CHAINLINK_ABI) with client.batch_requests() as batch: for call in ( - collateral_token.functions.symbol(), - collateral_token.functions.decimals(), - borrow_token.functions.symbol(), - borrow_token.functions.decimals(), morpho_oracle.functions.price(), price_feed.functions.decimals(), price_feed.functions.latestRoundData(), @@ -412,10 +412,6 @@ def _read_snapshot(config: StrategyConfig, *, include_rates: bool) -> StrategySn aux = client.execute_batch(batch) ( - collateral_symbol, - collateral_decimals, - borrow_symbol, - borrow_decimals, oracle_price, price_feed_decimals, price_round, @@ -431,9 +427,9 @@ def _read_snapshot(config: StrategyConfig, *, include_rates: bool) -> StrategySn borrow_price_usd_e8 = borrow_price_answer * USD_SCALE // (10 ** int(price_feed_decimals)) collateral_price_borrow_wad = ( int(oracle_price) - * (10 ** int(collateral_decimals)) + * (10**config.collateral_decimals) * WAD - // (ORACLE_PRICE_SCALE * (10 ** int(borrow_decimals))) + // (ORACLE_PRICE_SCALE * (10**config.borrow_decimals)) ) collateral_price_usd_e8 = collateral_price_borrow_wad * borrow_price_usd_e8 // WAD @@ -443,7 +439,7 @@ def _read_snapshot(config: StrategyConfig, *, include_rates: bool) -> StrategySn morpho = client.get_contract(morpho_address, MORPHO_ABI) apr_oracle = client.get_contract(Web3.to_checksum_address(YEARN_APR_ORACLE), APR_ORACLE_ABI) with client.batch_requests() as batch: - batch.add(morpho.functions.market(market_id)) + batch.add(morpho.functions.market(config.market_id)) batch.add(apr_oracle.functions.getStrategyApr(lender_vault_address, 0)) market_raw, lender_apr_raw = client.execute_batch(batch) @@ -468,11 +464,11 @@ def _read_snapshot(config: StrategyConfig, *, include_rates: bool) -> StrategySn return StrategySnapshot( timestamp=block_timestamp, - strategy_name=str(strategy_name), - collateral_symbol=str(collateral_symbol), - collateral_decimals=int(collateral_decimals), - borrow_symbol=str(borrow_symbol), - borrow_decimals=int(borrow_decimals), + strategy_name=config.name, + collateral_symbol=config.collateral_symbol, + collateral_decimals=config.collateral_decimals, + borrow_symbol=config.borrow_symbol, + borrow_decimals=config.borrow_decimals, collateral=int(collateral), debt=int(debt), lent=int(lent), @@ -530,12 +526,12 @@ def _update_rate_samples(config: StrategyConfig, snapshot: StrategySnapshot, *, return samples -def _alert_state_key(config: StrategyConfig, checks: str) -> str: +def _state_key(config: StrategyConfig, checks: str) -> str: return f"{config.address.lower()}:{checks}" def _should_send_alert(config: StrategyConfig, evaluation: Evaluation, now: int, checks: str = CHECK_ALL) -> bool: - key = _alert_state_key(config, checks) + key = _state_key(config, checks) raw = store.state_get(ALERT_STATE_NAMESPACE, key) previous: dict[str, Any] = {} if raw: @@ -559,18 +555,14 @@ def _record_alert_sent(config: StrategyConfig, evaluation: Evaluation, now: int, fingerprint = "|".join(sorted(evaluation.issue_codes)) store.state_set( ALERT_STATE_NAMESPACE, - _alert_state_key(config, checks), + _state_key(config, checks), json.dumps({"fingerprint": fingerprint, "last_alert": now}), ) -def _error_state_key(config: StrategyConfig, checks: str) -> str: - return f"{config.address.lower()}:{checks}" - - def _should_send_error(config: StrategyConfig, checks: str, error_type: str, now: int) -> bool: """Return whether a monitor error is new or due for its daily reminder.""" - raw = store.state_get(ERROR_STATE_NAMESPACE, _error_state_key(config, checks)) + raw = store.state_get(ERROR_STATE_NAMESPACE, _state_key(config, checks)) previous: dict[str, Any] = {} if raw: try: @@ -584,13 +576,13 @@ def _should_send_error(config: StrategyConfig, checks: str, error_type: str, now def _record_error_sent(config: StrategyConfig, checks: str, error_type: str, now: int) -> None: store.state_set( ERROR_STATE_NAMESPACE, - _error_state_key(config, checks), + _state_key(config, checks), json.dumps({"fingerprint": error_type, "last_alert": now}), ) def _clear_error_state(config: StrategyConfig, checks: str, now: int) -> None: - key = _error_state_key(config, checks) + key = _state_key(config, checks) if store.state_get(ERROR_STATE_NAMESPACE, key): store.state_set(ERROR_STATE_NAMESPACE, key, json.dumps({"fingerprint": "", "last_alert": now})) @@ -679,7 +671,15 @@ def run_strategy(config: StrategyConfig, *, checks: str = CHECK_ALL, dry_run: bo if dry_run: logger.warning("Dry run would alert: %s", message.replace("\n", " | ")) elif _should_send_alert(config, evaluation, snapshot.timestamp, checks): - send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL), plain_text=True) + send_alert( + Alert( + AlertSeverity.MEDIUM, + message, + PROTOCOL, + channel=resolve_channel(CURATION_CHANNEL, PROTOCOL), + ), + plain_text=True, + ) _record_alert_sent(config, evaluation, snapshot.timestamp, checks) @@ -711,6 +711,7 @@ def main() -> None: f"Lender Borrower Monitor Error ({args.checks})\n" f"{config.name}\n{error_type}: {exc}\n{config.joc_url}", PROTOCOL, + channel=resolve_channel(CURATION_CHANNEL, PROTOCOL), ), plain_text=True, )