diff --git a/.env.example b/.env.example index 568feb6d..6ba1488d 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,12 @@ TELEGRAM_CHAT_ID_DEFAULT=your-default-chat-id TELEGRAM_BOT_TOKEN_AAVE=your-aave-bot-token TELEGRAM_CHAT_ID_AAVE=your-aave-chat-id +# Depeg monitoring (prices/main.py) — separate channels for LRTs and stablecoins +TELEGRAM_BOT_TOKEN_LRT=your-lrt-bot-token +TELEGRAM_CHAT_ID_LRT=your-lrt-chat-id +TELEGRAM_BOT_TOKEN_STABLES=your-stables-bot-token +TELEGRAM_CHAT_ID_STABLES=your-stables-chat-id + # Protocol-specific threshold settings AAVE_ALERT_THRESHOLD=0.95 AAVE_CRITICAL_THRESHOLD=0.98 diff --git a/.github/workflows/_run-monitoring.yml b/.github/workflows/_run-monitoring.yml index fe53f0d5..57454b64 100644 --- a/.github/workflows/_run-monitoring.yml +++ b/.github/workflows/_run-monitoring.yml @@ -55,10 +55,12 @@ env: TELEGRAM_BOT_TOKEN_COMP: ${{ secrets.TELEGRAM_BOT_TOKEN_COMP }} TELEGRAM_BOT_TOKEN_EULER: ${{ secrets.TELEGRAM_BOT_TOKEN_EULER }} TELEGRAM_BOT_TOKEN_LIDO: ${{ secrets.TELEGRAM_BOT_TOKEN_LIDO }} + TELEGRAM_BOT_TOKEN_LRT: ${{ secrets.TELEGRAM_BOT_TOKEN_LRT }} TELEGRAM_BOT_TOKEN_PEGS: ${{ secrets.TELEGRAM_BOT_TOKEN_PEGS }} TELEGRAM_BOT_TOKEN_PENDLE: ${{ secrets.TELEGRAM_BOT_TOKEN_PENDLE }} TELEGRAM_BOT_TOKEN_SILO: ${{ secrets.TELEGRAM_BOT_TOKEN_SILO }} TELEGRAM_BOT_TOKEN_SPARK: ${{ secrets.TELEGRAM_BOT_TOKEN_SPARK }} + TELEGRAM_BOT_TOKEN_STABLES: ${{ secrets.TELEGRAM_BOT_TOKEN_STABLES }} TELEGRAM_BOT_TOKEN_STARGATE: ${{ secrets.TELEGRAM_BOT_TOKEN_STARGATE }} # ── Telegram Chat IDs ── @@ -82,6 +84,7 @@ env: TELEGRAM_CHAT_ID_RTOKEN: ${{ secrets.TELEGRAM_CHAT_ID_RTOKEN }} TELEGRAM_CHAT_ID_SILO: ${{ secrets.TELEGRAM_CHAT_ID_SILO }} TELEGRAM_CHAT_ID_SPARK: ${{ secrets.TELEGRAM_CHAT_ID_SPARK }} + TELEGRAM_CHAT_ID_STABLES: ${{ secrets.TELEGRAM_CHAT_ID_STABLES }} TELEGRAM_CHAT_ID_STARGATE: ${{ secrets.TELEGRAM_CHAT_ID_STARGATE }} TELEGRAM_CHAT_ID_USD0: ${{ secrets.TELEGRAM_CHAT_ID_USD0 }} TELEGRAM_CHAT_ID_USDAI: ${{ secrets.TELEGRAM_CHAT_ID_USDAI }} diff --git a/.github/workflows/hourly.yml b/.github/workflows/hourly.yml index f050868d..7aeb4ebf 100644 --- a/.github/workflows/hourly.yml +++ b/.github/workflows/hourly.yml @@ -27,6 +27,7 @@ jobs: lrt-pegs/fluid/main.py rtoken/monitor_rtoken.py lrt-pegs/origin_protocol.py + prices/main.py # euler/markets.py infinifi/main.py usdai/main.py diff --git a/prices/README.md b/prices/README.md new file mode 100644 index 00000000..68f49c5a --- /dev/null +++ b/prices/README.md @@ -0,0 +1,70 @@ +# Depeg Monitoring + +Centralized depeg monitoring for LRTs and stablecoins. Runs two types of checks: + +1. **Fundamental oracle check** — reads Redstone on-chain push oracles. Each asset uses its own threshold (see table below); breaching it triggers a CRITICAL alert. +2. **DefiLlama market price check** — fetches USD prices, computes a ratio vs the underlying reference (ETH or USD), then normalizes that ratio against a per-asset `fair_value` so accruing LRTs are checked against their accrued rate rather than a flat 1:1 peg. Deviation below the per-asset threshold triggers a CRITICAL alert. + +When DefiLlama returns no price for a configured asset, a MEDIUM alert fires so coverage gaps are visible rather than silently skipped. + +Alerts route to the owning protocol's Telegram channel (e.g. `lrt`, `cap`, `stables`). The workflow exports the per-protocol `TELEGRAM_BOT_TOKEN_*` and `TELEGRAM_CHAT_ID_*` pairs; bot tokens fall back to `TELEGRAM_BOT_TOKEN_DEFAULT`, but chat IDs have no fallback and must be set. + +> **Fair-value floors rot.** Accruing LRTs' real exchange rate grows over time (e.g. weETH `fair_value=1.07` today may be `1.10+` in 12 months). When the accrued rate outruns the floor, real depegs become invisible — a 2% drop from 1.10 to 1.078 yields `1.078/1.07 ≈ 1.007` and won't fire. Bump LRT `fair_value` entries quarterly against current Redstone fundamentals. + +## Fundamental Oracles + +| Asset | Channel | Oracle address | Threshold | Tenderly alert | +|-------|---------|----------------|-----------|----------------| +| LBTC | `lrt` | `0xb415eAA355D8440ac7eCB602D3fb67ccC1f0bc81` | 0.998 | `eca272ef-979a-47b3-a7f0-2e67172889bb` (value change between blocks) | +| cUSD | `cap` | `0x9a5a3c3ed0361505cc1d4e824b3854de5724434a` | 0.9998 | `316f440e-457b-4cfa-a69e-f7f54230bf44` (`latestAnswer` < 0.9998) | + +Both oracles implement AggregatorV3 (`latestRoundData()`, 8 decimals). + +## DefiLlama-Monitored Assets + +These assets do not have on-chain fundamental push oracles on Ethereum mainnet. Redstone provides off-chain fundamental feeds (pull model) for weETH, ezETH, rsETH, and pufETH, but they require calldata injection at transaction time and cannot be read directly on-chain. + +Per-asset `fair_value` is a conservative floor under the current Redstone fundamental. The check compares `market_ratio / fair_value` against `threshold` — so a 2% deviation alert on weETH fires when the market ratio drops below `1.07 × 0.98 ≈ 1.0486` ETH, not below `0.98` ETH flat. + +### LRTs (vs ETH) + +| Token | Address | fair_value | threshold | +|--------|---------|------------|-----------| +| weETH | `0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee` | 1.07 | 0.98 | +| ezETH | `0xbf5495Efe5DB9ce00f80364C8B423567e58d2110` | 1.06 | 0.98 | +| rsETH | `0xA1290d69c65A6Fe4DF752f95823Fae25cB99e5A7` | 1.05 | 0.98 | +| pufETH | `0xD9A442856C234a39a81a089C06451EBAa4306a72` | 1.05 | 0.98 | +| osETH | `0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38` | 1.00 | 0.98 | +| rswETH | `0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0` | 1.00 | 0.98 | +| mETH | `0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa` | 1.00 | 0.98 | + +### Stablecoins (vs USD) + +| Token | Address | fair_value | threshold | Notes | +|--------|---------|------------|-----------|-------| +| FDUSD | `0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409` | 1.00 | 0.98 | | +| deUSD | `0x15700B564Ca08D9439C58cA5053166E8317aa138` | 1.00 | 0.98 | | +| USD0 | `0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5` | 1.00 | 0.98 | | +| USD0++ | `0x35D8949372D46B7a3D5A56006AE77B215fc69bC0` | 1.00 | 0.90 | 4-year bond; legitimately trades at a discount. | +| USDe | `0x4c9EDD5852cd905f086C759E8383e09bff1E68B3` | 1.00 | 0.98 | Also covered in `stables/main.py` at threshold 0.97 — intentional overlap; this module trips first. | + +## Tenderly Alert Coverage + +| Asset | Tenderly Alert | Status | +|-------|---------------|--------| +| LBTC | `eca272ef-...` (value change between blocks) | Covered | +| cUSD | `316f440e-...` (latestAnswer < 0.9998) | Covered | +| weETH | — | **Needs Tenderly alert** if on-chain push oracle deployed | +| ezETH | — | **Needs Tenderly alert** if on-chain push oracle deployed | +| rsETH | — | **Needs Tenderly alert** if on-chain push oracle deployed | +| pufETH | — | **Needs Tenderly alert** if on-chain push oracle deployed | +| Others | N/A (DefiLlama only) | Monitored by this script | + +## Creating Tenderly Alerts for New Oracles + +If Redstone deploys on-chain push oracles for weETH, ezETH, rsETH, or pufETH on Ethereum mainnet, create Tenderly alerts with: +- **Network**: Ethereum Mainnet +- **Contract address**: The oracle contract +- **Alert type**: Transaction — function call `latestAnswer()` returns value below threshold +- **Threshold**: 99800000 (0.998 with 8 decimals) for LRTs, 99980000 (0.9998) for stables +- **Delivery**: Telegram channel for the respective protocol diff --git a/prices/__init__.py b/prices/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/prices/abi/AggregatorV3.json b/prices/abi/AggregatorV3.json new file mode 100644 index 00000000..93f05021 --- /dev/null +++ b/prices/abi/AggregatorV3.json @@ -0,0 +1,22 @@ +[ + { + "inputs": [], + "name": "latestRoundData", + "outputs": [ + {"name": "roundId", "type": "uint80"}, + {"name": "answer", "type": "int256"}, + {"name": "startedAt", "type": "uint256"}, + {"name": "updatedAt", "type": "uint256"}, + {"name": "answeredInRound", "type": "uint80"} + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [{"name": "", "type": "uint8"}], + "stateMutability": "view", + "type": "function" + } +] diff --git a/prices/main.py b/prices/main.py new file mode 100644 index 00000000..ec12b132 --- /dev/null +++ b/prices/main.py @@ -0,0 +1,226 @@ +"""Depeg monitoring via on-chain oracles and DefiLlama market-ratio checks. + +Two signal sources, both routed to the owning protocol's Telegram channel: + +1. **Oracle check** — reads Redstone fundamental push oracles (AggregatorV3). Each + asset carries its own threshold; breaching it is CRITICAL. + +2. **DefiLlama market check** — fetches market prices and computes a + ``market_ratio / fair_value`` deviation. ``market_ratio`` is ``price / ETH`` + for LRTs or the USD price itself for stables. ``fair_value`` is a per-asset + floor (1.0 for stables, > 1 for accruing LRTs) so accruing LRTs are checked + against their accrued rate rather than a flat 1:1 peg. Deviation below + ``threshold`` is CRITICAL. +""" + +from dataclasses import dataclass +from decimal import Decimal + +from utils.abi import load_abi +from utils.alert import Alert, AlertSeverity, send_alert +from utils.chains import Chain +from utils.defillama import fetch_prices +from utils.logging import get_logger +from utils.web3_wrapper import ChainManager + +logger = get_logger("prices") + +# Reference token for LRT/ETH ratio computation +WETH_KEY = "ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + +AGGREGATOR_V3_ABI = load_abi("prices/abi/AggregatorV3.json") + + +@dataclass(frozen=True) +class OracleAsset: + """Asset monitored via on-chain Redstone fundamental oracle (AggregatorV3).""" + + symbol: str + oracle_address: str + decimals: int + protocol: str + threshold: Decimal # asset-specific; matches the Tenderly alert documented in README + + +@dataclass(frozen=True) +class DefiLlamaAsset: + """Asset monitored via DefiLlama market price. + + ``fair_value`` is the expected ratio vs the ``underlying`` reference (ETH or USD). + Accruing LRTs trade above 1 ETH, so a flat 1.0 baseline would miss real depegs + until parity. Per-asset fair values catch smaller deviations from the accrued + value but must be bumped periodically — see README. + """ + + symbol: str + defillama_key: str + underlying: str # "ETH" or "USD" + protocol: str + fair_value: Decimal = Decimal("1.0") + threshold: Decimal = Decimal("0.98") # 2% deviation from fair_value + + +# Mainnet-only today. If non-mainnet oracle assets are added, group by chain. +ORACLE_ASSETS: list[OracleAsset] = [ + # LBTC/BTC fundamental — Redstone push, 24h heartbeat / 1% deviation + # Tenderly alert: eca272ef-979a-47b3-a7f0-2e67172889bb + OracleAsset("LBTC", "0xb415eAA355D8440ac7eCB602D3fb67ccC1f0bc81", 8, "lrt", Decimal("0.998")), + # cUSD/USD fundamental — Redstone push + # Tenderly alert: 316f440e-457b-4cfa-a69e-f7f54230bf44 fires at latestAnswer < 0.9998 + OracleAsset("cUSD", "0x9a5a3c3ed0361505cc1d4e824b3854de5724434a", 8, "cap", Decimal("0.9998")), +] + +# DefiLlama-monitored assets. No on-chain push oracle available on Ethereum mainnet +# for these; Redstone offers off-chain fundamental feeds for some LRTs but they +# require calldata injection and cannot be read directly on-chain. +DEFILLAMA_ASSETS: list[DefiLlamaAsset] = [ + # ---- LRTs (vs ETH) ---- + DefiLlamaAsset("weETH", "ethereum:0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", "ETH", "lrt", Decimal("1.07")), + DefiLlamaAsset("ezETH", "ethereum:0xbf5495Efe5DB9ce00f80364C8B423567e58d2110", "ETH", "lrt", Decimal("1.06")), + DefiLlamaAsset("rsETH", "ethereum:0xA1290d69c65A6Fe4DF752f95823Fae25cB99e5A7", "ETH", "lrt", Decimal("1.05")), + DefiLlamaAsset("pufETH", "ethereum:0xD9A442856C234a39a81a089C06451EBAa4306a72", "ETH", "lrt", Decimal("1.05")), + # No documented off-chain fundamental feed; 1.0 ETH is a catastrophic-depeg floor only. + DefiLlamaAsset("osETH", "ethereum:0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38", "ETH", "lrt"), + DefiLlamaAsset("rswETH", "ethereum:0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0", "ETH", "lrt"), + DefiLlamaAsset("mETH", "ethereum:0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa", "ETH", "lrt"), + # ---- Stables (vs USD) ---- + DefiLlamaAsset("FDUSD", "ethereum:0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409", "USD", "stables"), + DefiLlamaAsset("deUSD", "ethereum:0x15700B564Ca08D9439C58cA5053166E8317aa138", "USD", "stables"), + DefiLlamaAsset("USD0", "ethereum:0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5", "USD", "stables"), + # USD0++ is a ~4-year locked bond and legitimately trades at a discount vs USD0; + # only alert on catastrophic dislocation. + DefiLlamaAsset( + "USD0++", + "ethereum:0x35D8949372D46B7a3D5A56006AE77B215fc69bC0", + "USD", + "stables", + threshold=Decimal("0.90"), + ), + # USDe is also covered in stables/main.py with threshold 0.97 — duplicate signal + # is intentional: this module enforces a tighter 0.98 floor. + DefiLlamaAsset("USDe", "ethereum:0x4c9EDD5852cd905f086C759E8383e09bff1E68B3", "USD", "stables"), +] + + +def check_oracle_assets() -> None: + """Read on-chain fundamental oracles; alert per-protocol on any depeg.""" + if not ORACLE_ASSETS: + return + + client = ChainManager.get_client(Chain.MAINNET) + with client.batch_requests() as batch: + for asset in ORACLE_ASSETS: + contract = client.eth.contract( + address=client.w3.to_checksum_address(asset.oracle_address), + abi=AGGREGATOR_V3_ABI, + ) + batch.add(contract.functions.latestRoundData()) + responses = client.execute_batch(batch) + + if len(responses) != len(ORACLE_ASSETS): + logger.error("Expected %d oracle responses, got %d", len(ORACLE_ASSETS), len(responses)) + return + + depegged_by_protocol: dict[str, list[tuple[str, Decimal, Decimal]]] = {} + for asset, result in zip(ORACLE_ASSETS, responses): + try: + # latestRoundData returns (roundId, answer, startedAt, updatedAt, answeredInRound) + answer = Decimal(str(result[1])) / Decimal(10**asset.decimals) + except (IndexError, TypeError) as exc: + logger.error("Failed to parse oracle response for %s: %s", asset.symbol, exc) + send_alert(Alert(AlertSeverity.MEDIUM, f"Oracle parse failed for {asset.symbol}: {exc}", asset.protocol)) + continue + + logger.info("%s oracle price: %s (threshold: %s)", asset.symbol, answer, asset.threshold) + if answer < asset.threshold: + depegged_by_protocol.setdefault(asset.protocol, []).append((asset.symbol, answer, asset.threshold)) + + for protocol, depegged in depegged_by_protocol.items(): + _send_depeg_alert(depegged, protocol, "Oracle") + + +def check_defillama_assets() -> None: + """Check DefiLlama market prices, normalizing each asset by its fair_value.""" + if not DEFILLAMA_ASSETS: + return + + needs_eth = any(a.underlying == "ETH" for a in DEFILLAMA_ASSETS) + token_keys = list({a.defillama_key for a in DEFILLAMA_ASSETS} | ({WETH_KEY} if needs_eth else set())) + + try: + prices = fetch_prices(token_keys) + except Exception as exc: + logger.warning("Failed to fetch DefiLlama prices: %s", exc) + # Notify every affected protocol so a fetch outage isn't routed to only one channel. + for protocol in {a.protocol for a in DEFILLAMA_ASSETS}: + send_alert(Alert(AlertSeverity.LOW, f"Depeg price fetch failed: {exc}", protocol)) + return + + eth_price = prices.get(WETH_KEY) if needs_eth else None + if needs_eth and not eth_price: + logger.error("Missing ETH reference price from DefiLlama") + for protocol in {a.protocol for a in DEFILLAMA_ASSETS if a.underlying == "ETH"}: + send_alert(Alert(AlertSeverity.MEDIUM, "Missing ETH reference price from DefiLlama", protocol)) + # Don't return — USD-denominated stables can still be checked. + + depegged_by_protocol: dict[str, list[tuple[str, Decimal, Decimal]]] = {} + missing_by_protocol: dict[str, list[str]] = {} + + for asset in DEFILLAMA_ASSETS: + if asset.underlying == "ETH" and not eth_price: + continue # already alerted above + + price = prices.get(asset.defillama_key) + if price is None: + logger.warning("No price returned for %s (%s)", asset.symbol, asset.defillama_key) + missing_by_protocol.setdefault(asset.protocol, []).append(asset.symbol) + continue + + market_ratio = price / eth_price if asset.underlying == "ETH" else price + # Normalize against fair_value so accruing LRTs are checked against accrued rate. + deviation = market_ratio / asset.fair_value + + logger.info( + "%s price: $%s, %s ratio: %s, fair: %s, deviation: %s (threshold: %s)", + asset.symbol, + price, + asset.underlying, + market_ratio, + asset.fair_value, + deviation, + asset.threshold, + ) + + if deviation < asset.threshold: + depegged_by_protocol.setdefault(asset.protocol, []).append((asset.symbol, deviation, asset.threshold)) + + for protocol, symbols in missing_by_protocol.items(): + send_alert( + Alert( + AlertSeverity.MEDIUM, + f"DefiLlama returned no price for: {', '.join(symbols)} — depeg coverage degraded", + protocol, + ) + ) + + for protocol, depegged in depegged_by_protocol.items(): + _send_depeg_alert(depegged, protocol, "DefiLlama") + + +def _send_depeg_alert(depegged: list[tuple[str, Decimal, Decimal]], protocol: str, source: str) -> None: + """Send CRITICAL alert listing all depegged assets with their per-asset thresholds.""" + lines = [f"*{symbol}*: {value:.4f} (threshold {threshold})" for symbol, value, threshold in depegged] + message = f"Depeg detected ({source}):\n" + "\n".join(lines) + send_alert(Alert(AlertSeverity.CRITICAL, message, protocol)) + + +def main() -> None: + """Run depeg monitoring for all tracked assets.""" + logger.info("Starting depeg monitoring...") + check_oracle_assets() + check_defillama_assets() + logger.info("Depeg monitoring complete.") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 7434bf2c..5e53791d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ build-backend = "setuptools.build_meta" packages = [ "aave", "bad-debt", "cap", "compound", "ethena", "euler", "fluid", "infinifi", "lido", "lrt-pegs", "maker", "moonwell", - "maple", "morpho", "pendle", "resolv", "rtoken", + "maple", "morpho", "pendle", "prices", "resolv", "rtoken", "safe", "silo", "spark", "stargate", "timelock", "usd0", "usdai", "utils", "yearn", ] diff --git a/tests/test_prices.py b/tests/test_prices.py new file mode 100644 index 00000000..6db81124 --- /dev/null +++ b/tests/test_prices.py @@ -0,0 +1,115 @@ +"""Tests for prices/main.py — DefiLlama market-ratio depeg checks. + +Focuses on the fair-value normalization math: a 2% deviation against an asset's +fair_value should fire, but the same absolute price against a 1.0 baseline +should not. Network and Telegram calls are stubbed. +""" + +import importlib +import sys +import types +import unittest +from decimal import Decimal +from unittest.mock import MagicMock, patch + + +def _import_prices_main(): + """Fresh import of prices.main with a stubbed defillama_sdk.""" + fake_sdk = types.ModuleType("defillama_sdk") + fake_sdk.DefiLlama = MagicMock() + sys.modules["defillama_sdk"] = fake_sdk + sys.modules.pop("utils.defillama", None) + sys.modules.pop("prices.main", None) + return importlib.import_module("prices.main") + + +class TestDefiLlamaFairValue(unittest.TestCase): + """check_defillama_assets normalizes market_ratio by per-asset fair_value.""" + + def setUp(self): + self.prices = _import_prices_main() + # Patch send_alert + fetch_prices on the module under test. + self.send_alert = patch.object(self.prices, "send_alert").start() + self.fetch_prices = patch.object(self.prices, "fetch_prices").start() + self.addCleanup(patch.stopall) + + def _set_assets(self, assets): + patch.object(self.prices, "DEFILLAMA_ASSETS", assets).start() + + def test_lrt_above_floor_does_not_alert(self): + # weETH @ 1.07 ETH = exactly fair_value; deviation = 1.0 > 0.98 → no alert + asset = self.prices.DefiLlamaAsset("weETH", "ethereum:0xweeth", "ETH", "lrt", Decimal("1.07")) + self._set_assets([asset]) + self.fetch_prices.return_value = { + self.prices.WETH_KEY: Decimal("2000"), + "ethereum:0xweeth": Decimal("2140"), # 2140 / 2000 = 1.07 + } + self.prices.check_defillama_assets() + self.send_alert.assert_not_called() + + def test_lrt_below_floor_alerts_critical(self): + # weETH at 1.04 ETH against fair_value 1.07: deviation = 1.04/1.07 ≈ 0.972 < 0.98 + asset = self.prices.DefiLlamaAsset("weETH", "ethereum:0xweeth", "ETH", "lrt", Decimal("1.07")) + self._set_assets([asset]) + self.fetch_prices.return_value = { + self.prices.WETH_KEY: Decimal("2000"), + "ethereum:0xweeth": Decimal("2080"), # 2080 / 2000 = 1.04 + } + self.prices.check_defillama_assets() + self.send_alert.assert_called_once() + alert = self.send_alert.call_args.args[0] + self.assertEqual(alert.severity, self.prices.AlertSeverity.CRITICAL) + self.assertEqual(alert.protocol, "lrt") + self.assertIn("weETH", alert.message) + + def test_flat_baseline_would_miss_lrt_depeg(self): + # Same 1.04 ETH price, but fair_value=1.0 — deviation = 1.04 > 0.98, no alert. + # This is the bug the fair_value design prevents. + asset = self.prices.DefiLlamaAsset("weETH-flat", "ethereum:0xweeth", "ETH", "lrt", Decimal("1.0")) + self._set_assets([asset]) + self.fetch_prices.return_value = { + self.prices.WETH_KEY: Decimal("2000"), + "ethereum:0xweeth": Decimal("2080"), + } + self.prices.check_defillama_assets() + self.send_alert.assert_not_called() + + def test_stable_below_threshold_alerts(self): + # FDUSD at $0.97 vs 1.0 fair_value: deviation = 0.97 < 0.98 → alert + asset = self.prices.DefiLlamaAsset("FDUSD", "ethereum:0xfdusd", "USD", "stables") + self._set_assets([asset]) + self.fetch_prices.return_value = {"ethereum:0xfdusd": Decimal("0.97")} + self.prices.check_defillama_assets() + self.send_alert.assert_called_once() + alert = self.send_alert.call_args.args[0] + self.assertEqual(alert.severity, self.prices.AlertSeverity.CRITICAL) + self.assertEqual(alert.protocol, "stables") + + def test_missing_price_emits_medium_coverage_alert(self): + asset = self.prices.DefiLlamaAsset("FDUSD", "ethereum:0xfdusd", "USD", "stables") + self._set_assets([asset]) + self.fetch_prices.return_value = {} # no price returned + self.prices.check_defillama_assets() + self.send_alert.assert_called_once() + alert = self.send_alert.call_args.args[0] + self.assertEqual(alert.severity, self.prices.AlertSeverity.MEDIUM) + self.assertEqual(alert.protocol, "stables") + self.assertIn("FDUSD", alert.message) + + def test_fetch_failure_notifies_each_affected_protocol(self): + assets = [ + self.prices.DefiLlamaAsset("weETH", "ethereum:0xweeth", "ETH", "lrt", Decimal("1.07")), + self.prices.DefiLlamaAsset("FDUSD", "ethereum:0xfdusd", "USD", "stables"), + ] + self._set_assets(assets) + self.fetch_prices.side_effect = RuntimeError("upstream timeout") + self.prices.check_defillama_assets() + # One LOW alert per affected protocol, not just the first. + protocols = {call.args[0].protocol for call in self.send_alert.call_args_list} + self.assertEqual(protocols, {"lrt", "stables"}) + for call in self.send_alert.call_args_list: + self.assertEqual(call.args[0].severity, self.prices.AlertSeverity.LOW) + + +if __name__ == "__main__": + unittest.main()