From f767695e21ceb643e83db4904751d7ef5176ab39 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 31 Mar 2026 10:26:07 +0200 Subject: [PATCH 1/3] feat(prices): add depeg monitoring for LRTs and stablecoins Add centralized depeg monitoring using Redstone fundamental oracles where available (LBTC, cUSD) and DefiLlama market pricing as fallback for weETH, ezETH, rsETH, pufETH, osETH, rswETH, mETH and stablecoins. Oracle-based assets alert on any depeg (below 0.998). DefiLlama-based assets alert on 2%+ depeg (below 0.98). LRT alerts route to a single "lrt" channel with token symbol. Closes #196 Co-Authored-By: Claude Opus 4.6 (1M context) --- prices/README.md | 68 +++++++++++ prices/__init__.py | 0 prices/abi/AggregatorV3.json | 22 ++++ prices/main.py | 228 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 prices/README.md create mode 100644 prices/__init__.py create mode 100644 prices/abi/AggregatorV3.json create mode 100644 prices/main.py diff --git a/prices/README.md b/prices/README.md new file mode 100644 index 00000000..9b97bebb --- /dev/null +++ b/prices/README.md @@ -0,0 +1,68 @@ +# Depeg Monitoring + +Centralized depeg monitoring for LRTs and stablecoins. Runs two types of checks: + +1. **Fundamental oracle check** — reads Redstone on-chain push oracles. Any depeg (below 0.998) triggers a CRITICAL alert. +2. **DefiLlama market price check** — fetches USD prices and computes ratios vs underlying (ETH or USD). A 2%+ depeg (below 0.98) triggers a CRITICAL alert. + +All LRT alerts are routed to the `lrt` Telegram channel. Stablecoin alerts go to `stables`. + +## Fundamental Oracles + +### LBTC (Lombard Bitcoin) +- **Oracle**: Redstone LBTC_FUNDAMENTAL push oracle +- **Address**: `0xb415eAA355D8440ac7eCB602D3fb67ccC1f0bc81` (Ethereum Mainnet) +- **Interface**: AggregatorV3 (`latestRoundData()`, 8 decimals) +- **Update**: 24h heartbeat / 1% deviation +- **Tenderly alert**: `eca272ef-979a-47b3-a7f0-2e67172889bb` — monitors value changes between blocks + +### cUSD (CAP Protocol) +- **Oracle**: Redstone cUSD_FUNDAMENTAL push oracle +- **Address**: `0x9a5a3c3ed0361505cc1d4e824b3854de5724434a` (Ethereum Mainnet) +- **Interface**: AggregatorV3 (`latestRoundData()`, 8 decimals) +- **Tenderly alert**: `316f440e-457b-4cfa-a69e-f7f54230bf44` — alerts when `latestAnswer()` drops below 99980000 (0.9998) + +## 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, pufETH but they require calldata injection at transaction time and cannot be read directly on-chain. + +### LRTs (vs ETH) +| Token | Address | Redstone fundamental (off-chain) | +|--------|---------|----------------------------------| +| weETH | `0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee` | weETH_FUNDAMENTAL (~1.09) | +| ezETH | `0xbf5495Efe5DB9ce00f80364C8B423567e58d2110` | ezETH_FUNDAMENTAL (~1.08) | +| rsETH | `0xA1290d69c65A6Fe4DF752f95823Fae25cB99e5A7` | rsETH_FUNDAMENTAL (~1.07) | +| pufETH | `0xD9A442856C234a39a81a089C06451EBAa4306a72` | pufETH_FUNDAMENTAL (~1.07) | +| osETH | `0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38` | — | +| rswETH | `0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0` | — | +| mETH | `0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa` | — | + +### Stablecoins (vs USD) +| Token | Address | +|--------|---------| +| FDUSD | `0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409` | +| deUSD | `0x15700B564Ca08D9439C58cA5053166E8317aa138` | +| USD0 | `0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5` | +| USD0++ | `0x35D8949372D46B7a3D5A56006AE77B215fc69bC0` | +| USDe | `0x4c9EDD5852cd905f086C759E8383e09bff1E68B3` | + +## 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..2891766f --- /dev/null +++ b/prices/main.py @@ -0,0 +1,228 @@ +"""Depeg monitoring for LRTs and stablecoins. + +Uses Redstone fundamental oracles where available, falls back to DefiLlama pricing. +- Fundamental oracles: any depeg triggers CRITICAL alert +- DefiLlama pricing: 2%+ depeg triggers CRITICAL alert + +LRT alerts are sent to a single "lrt" protocol channel, each identified by token symbol. +Stablecoin alerts are sent to a "stables" protocol channel. +""" + +from dataclasses import dataclass +from decimal import Decimal, getcontext + +from defillama_sdk import DefiLlama + +from utils.abi import load_abi +from utils.alert import Alert, AlertSeverity, send_alert +from utils.chains import Chain +from utils.logging import get_logger +from utils.web3_wrapper import ChainManager + +getcontext().prec = 18 + +logger = get_logger("prices") + +LRT_PROTOCOL = "lrt" +STABLES_PROTOCOL = "stables" + +# Oracle threshold: any meaningful depeg from fundamental oracle is critical +ORACLE_DEPEG_THRESHOLD = Decimal("0.998") +# DefiLlama threshold: 2% depeg from market price is critical +DEFILLAMA_DEPEG_THRESHOLD = Decimal("0.98") + +# Reference tokens for LRT/BTC ratio computation via DefiLlama +WETH_KEY = "ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + +AGGREGATOR_V3_ABI = load_abi("prices/abi/AggregatorV3.json") + +_dl_client = DefiLlama() + + +@dataclass(frozen=True) +class OracleAsset: + """Asset monitored via on-chain Redstone fundamental oracle (AggregatorV3Interface).""" + + symbol: str + oracle_address: str + chain: Chain + decimals: int + protocol: str + + +@dataclass(frozen=True) +class DefiLlamaAsset: + """Asset monitored via DefiLlama market price.""" + + symbol: str + defillama_key: str + underlying: str # "ETH" or "USD" + protocol: str + + +# --------------------------------------------------------------------------- +# Oracle-monitored assets (Redstone fundamental push oracles) +# These implement AggregatorV3Interface with latestRoundData() +# --------------------------------------------------------------------------- +ORACLE_ASSETS: list[OracleAsset] = [ + # LBTC/BTC fundamental - Redstone push, 24h heartbeat / 1% deviation + # Tenderly alert: eca272ef-979a-47b3-a7f0-2e67172889bb + OracleAsset( + symbol="LBTC", + oracle_address="0xb415eAA355D8440ac7eCB602D3fb67ccC1f0bc81", + chain=Chain.MAINNET, + decimals=8, + protocol=LRT_PROTOCOL, + ), + # cUSD/USD fundamental - Redstone push + # Tenderly alert: 316f440e-457b-4cfa-a69e-f7f54230bf44 + OracleAsset( + symbol="cUSD", + oracle_address="0x9a5a3c3ed0361505cc1d4e824b3854de5724434a", + chain=Chain.MAINNET, + decimals=8, + protocol=STABLES_PROTOCOL, + ), +] + +# --------------------------------------------------------------------------- +# DefiLlama-monitored LRT assets (market price vs ETH) +# No on-chain fundamental push oracle available on Ethereum mainnet. +# Redstone provides off-chain fundamental feeds for weETH, ezETH, rsETH, +# pufETH but these are pull-model (not persistent on-chain contracts). +# --------------------------------------------------------------------------- +DEFILLAMA_LRTS: list[DefiLlamaAsset] = [ + DefiLlamaAsset("weETH", "ethereum:0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", "ETH", LRT_PROTOCOL), + DefiLlamaAsset("ezETH", "ethereum:0xbf5495Efe5DB9ce00f80364C8B423567e58d2110", "ETH", LRT_PROTOCOL), + DefiLlamaAsset("rsETH", "ethereum:0xA1290d69c65A6Fe4DF752f95823Fae25cB99e5A7", "ETH", LRT_PROTOCOL), + DefiLlamaAsset("pufETH", "ethereum:0xD9A442856C234a39a81a089C06451EBAa4306a72", "ETH", LRT_PROTOCOL), + DefiLlamaAsset("osETH", "ethereum:0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38", "ETH", LRT_PROTOCOL), + DefiLlamaAsset("rswETH", "ethereum:0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0", "ETH", LRT_PROTOCOL), + DefiLlamaAsset("mETH", "ethereum:0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa", "ETH", LRT_PROTOCOL), +] + +# --------------------------------------------------------------------------- +# DefiLlama-monitored stablecoins (market price vs $1 USD) +# Blue-chip stables (USDC, USDT, DAI) are excluded; they are Tier 1 and +# extremely unlikely to depeg. Focus on higher-risk stables. +# --------------------------------------------------------------------------- +DEFILLAMA_STABLES: list[DefiLlamaAsset] = [ + DefiLlamaAsset("FDUSD", "ethereum:0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409", "USD", STABLES_PROTOCOL), + DefiLlamaAsset("deUSD", "ethereum:0x15700B564Ca08D9439C58cA5053166E8317aa138", "USD", STABLES_PROTOCOL), + DefiLlamaAsset("USD0", "ethereum:0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5", "USD", STABLES_PROTOCOL), + DefiLlamaAsset("USD0++", "ethereum:0x35D8949372D46B7a3D5A56006AE77B215fc69bC0", "USD", STABLES_PROTOCOL), + DefiLlamaAsset("USDe", "ethereum:0x4c9EDD5852cd905f086C759E8383e09bff1E68B3", "USD", STABLES_PROTOCOL), +] + + +def check_oracle_assets() -> None: + """Check assets with on-chain fundamental oracles. Any depeg is CRITICAL.""" + # Group assets by chain for batch requests + mainnet_assets = [a for a in ORACLE_ASSETS if a.chain == Chain.MAINNET] + if not mainnet_assets: + return + + client = ChainManager.get_client(Chain.MAINNET) + + with client.batch_requests() as batch: + for asset in mainnet_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(mainnet_assets): + logger.error("Expected %d oracle responses, got %d", len(mainnet_assets), len(responses)) + return + + depegged_lrt: list[tuple[str, Decimal]] = [] + depegged_stables: list[tuple[str, Decimal]] = [] + + for asset, result in zip(mainnet_assets, responses): + try: + # latestRoundData returns (roundId, answer, startedAt, updatedAt, answeredInRound) + answer = Decimal(str(result[1])) / Decimal(10**asset.decimals) + logger.info("%s oracle price: %s (threshold: %s)", asset.symbol, answer, ORACLE_DEPEG_THRESHOLD) + + if answer < ORACLE_DEPEG_THRESHOLD: + if asset.protocol == LRT_PROTOCOL: + depegged_lrt.append((asset.symbol, answer)) + else: + depegged_stables.append((asset.symbol, answer)) + except Exception 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)) + + _send_depeg_alerts(depegged_lrt, LRT_PROTOCOL, "Oracle", ORACLE_DEPEG_THRESHOLD) + _send_depeg_alerts(depegged_stables, STABLES_PROTOCOL, "Oracle", ORACLE_DEPEG_THRESHOLD) + + +def check_defillama_assets() -> None: + """Check assets via DefiLlama market prices. 2%+ depeg is CRITICAL.""" + all_assets = DEFILLAMA_LRTS + DEFILLAMA_STABLES + token_keys = list({a.defillama_key for a in all_assets} | {WETH_KEY}) + + logger.info("Fetching prices for %d tokens from DefiLlama", len(token_keys)) + try: + result = _dl_client.prices.getCurrentPrices(token_keys) + except Exception as exc: + logger.warning("Failed to fetch DefiLlama prices: %s", exc) + send_alert(Alert(AlertSeverity.LOW, f"Depeg price fetch failed: {exc}", LRT_PROTOCOL)) + return + + coins = result.get("coins", {}) + prices = {key: Decimal(str(data["price"])) for key, data in coins.items() if "price" in data} + + eth_price = prices.get(WETH_KEY) + if not eth_price: + logger.error("Missing ETH reference price from DefiLlama") + send_alert(Alert(AlertSeverity.MEDIUM, "Missing ETH reference price from DefiLlama", LRT_PROTOCOL)) + return + + depegged_lrt: list[tuple[str, Decimal]] = [] + depegged_stables: list[tuple[str, Decimal]] = [] + + for asset in all_assets: + price = prices.get(asset.defillama_key) + if price is None: + logger.warning("No price returned for %s (%s)", asset.symbol, asset.defillama_key) + continue + + if asset.underlying == "ETH": + ratio = price / eth_price + else: + ratio = price # Already in USD, peg is $1 + + logger.info("%s price: $%s, ratio vs %s: %s", asset.symbol, price, asset.underlying, ratio) + + if ratio < DEFILLAMA_DEPEG_THRESHOLD: + if asset.protocol == LRT_PROTOCOL: + depegged_lrt.append((asset.symbol, ratio)) + else: + depegged_stables.append((asset.symbol, ratio)) + + _send_depeg_alerts(depegged_lrt, LRT_PROTOCOL, "DefiLlama", DEFILLAMA_DEPEG_THRESHOLD) + _send_depeg_alerts(depegged_stables, STABLES_PROTOCOL, "DefiLlama", DEFILLAMA_DEPEG_THRESHOLD) + + +def _send_depeg_alerts(depegged: list[tuple[str, Decimal]], protocol: str, source: str, threshold: Decimal) -> None: + """Send CRITICAL alert listing all depegged assets.""" + if not depegged: + return + lines = [f"*{symbol}*: {value:.4f}" for symbol, value in depegged] + message = f"Depeg detected ({source}, below {threshold}):\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 d9c8946f..c37d56bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,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", ] From b94dc2f9b1dc2f5f9fb5bd4e1b200707d94f0df2 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Wed, 22 Apr 2026 18:52:28 +0000 Subject: [PATCH 2/3] fix(prices): per-asset thresholds, workflow wiring, coverage alerts Address PR review feedback: - Oracle threshold is now per-asset; cUSD uses 0.9998 to match the documented Tenderly alert (was 0.998, so cUSD=0.99975 slipped through). - DefiLlamaAsset now carries fair_value and threshold so accruing LRTs are checked against their accrued ETH rate instead of a flat 1:1 peg. USD0++ gets a looser 0.90 floor because it's a 4-year bond that legitimately trades at a discount. - Missing DefiLlama prices now raise a MEDIUM alert (e.g. deUSD on the dry-run date) so coverage failures are visible. - Wire prices/main.py into the hourly workflow and add TELEGRAM_BOT_TOKEN_LRT/STABLES plus TELEGRAM_CHAT_ID_STABLES to _run-monitoring.yml; update .env.example accordingly. Co-Authored-By: Claude Opus 4.7 --- .env.example | 6 ++ .github/workflows/_run-monitoring.yml | 3 + .github/workflows/hourly.yml | 1 + prices/README.md | 62 +++++------ prices/main.py | 150 ++++++++++++++++++-------- 5 files changed, 146 insertions(+), 76 deletions(-) diff --git a/.env.example b/.env.example index 89b782bc..d75c0922 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,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 ef4b67ea..68dd9440 100644 --- a/.github/workflows/_run-monitoring.yml +++ b/.github/workflows/_run-monitoring.yml @@ -51,10 +51,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 ── @@ -77,6 +79,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 5fe60561..0660e4f4 100644 --- a/.github/workflows/hourly.yml +++ b/.github/workflows/hourly.yml @@ -24,6 +24,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 silo/ur_sniff.py diff --git a/prices/README.md b/prices/README.md index 9b97bebb..803daabd 100644 --- a/prices/README.md +++ b/prices/README.md @@ -2,49 +2,49 @@ Centralized depeg monitoring for LRTs and stablecoins. Runs two types of checks: -1. **Fundamental oracle check** — reads Redstone on-chain push oracles. Any depeg (below 0.998) triggers a CRITICAL alert. -2. **DefiLlama market price check** — fetches USD prices and computes ratios vs underlying (ETH or USD). A 2%+ depeg (below 0.98) triggers a CRITICAL alert. +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. -All LRT alerts are routed to the `lrt` Telegram channel. Stablecoin alerts go to `stables`. +When DefiLlama returns no price for a configured asset, a MEDIUM alert fires so coverage gaps are visible rather than silently skipped. + +LRT alerts route to the `lrt` Telegram channel. Stablecoin alerts route to `stables`. The workflow exports `TELEGRAM_BOT_TOKEN_LRT`, `TELEGRAM_BOT_TOKEN_STABLES`, `TELEGRAM_CHAT_ID_LRT`, and `TELEGRAM_CHAT_ID_STABLES`; bot tokens fall back to `TELEGRAM_BOT_TOKEN_DEFAULT`, but chat IDs have no fallback and must be set. ## Fundamental Oracles -### LBTC (Lombard Bitcoin) -- **Oracle**: Redstone LBTC_FUNDAMENTAL push oracle -- **Address**: `0xb415eAA355D8440ac7eCB602D3fb67ccC1f0bc81` (Ethereum Mainnet) -- **Interface**: AggregatorV3 (`latestRoundData()`, 8 decimals) -- **Update**: 24h heartbeat / 1% deviation -- **Tenderly alert**: `eca272ef-979a-47b3-a7f0-2e67172889bb` — monitors value changes between blocks +| Asset | Oracle address | Threshold | Tenderly alert | +|-------|----------------|-----------|----------------| +| LBTC | `0xb415eAA355D8440ac7eCB602D3fb67ccC1f0bc81` | 0.998 | `eca272ef-979a-47b3-a7f0-2e67172889bb` (value change between blocks) | +| cUSD | `0x9a5a3c3ed0361505cc1d4e824b3854de5724434a` | 0.9998 | `316f440e-457b-4cfa-a69e-f7f54230bf44` (`latestAnswer` < 0.9998) | -### cUSD (CAP Protocol) -- **Oracle**: Redstone cUSD_FUNDAMENTAL push oracle -- **Address**: `0x9a5a3c3ed0361505cc1d4e824b3854de5724434a` (Ethereum Mainnet) -- **Interface**: AggregatorV3 (`latestRoundData()`, 8 decimals) -- **Tenderly alert**: `316f440e-457b-4cfa-a69e-f7f54230bf44` — alerts when `latestAnswer()` drops below 99980000 (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, pufETH but they require calldata injection at transaction time and cannot be read directly on-chain. +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 | Redstone fundamental (off-chain) | -|--------|---------|----------------------------------| -| weETH | `0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee` | weETH_FUNDAMENTAL (~1.09) | -| ezETH | `0xbf5495Efe5DB9ce00f80364C8B423567e58d2110` | ezETH_FUNDAMENTAL (~1.08) | -| rsETH | `0xA1290d69c65A6Fe4DF752f95823Fae25cB99e5A7` | rsETH_FUNDAMENTAL (~1.07) | -| pufETH | `0xD9A442856C234a39a81a089C06451EBAa4306a72` | pufETH_FUNDAMENTAL (~1.07) | -| osETH | `0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38` | — | -| rswETH | `0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0` | — | -| mETH | `0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa` | — | + +| 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 | -|--------|---------| -| FDUSD | `0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409` | -| deUSD | `0x15700B564Ca08D9439C58cA5053166E8317aa138` | -| USD0 | `0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5` | -| USD0++ | `0x35D8949372D46B7a3D5A56006AE77B215fc69bC0` | -| USDe | `0x4c9EDD5852cd905f086C759E8383e09bff1E68B3` | + +| 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 | | ## Tenderly Alert Coverage diff --git a/prices/main.py b/prices/main.py index 2891766f..dc3b7602 100644 --- a/prices/main.py +++ b/prices/main.py @@ -1,11 +1,13 @@ """Depeg monitoring for LRTs and stablecoins. Uses Redstone fundamental oracles where available, falls back to DefiLlama pricing. -- Fundamental oracles: any depeg triggers CRITICAL alert -- DefiLlama pricing: 2%+ depeg triggers CRITICAL alert +Each asset carries its own depeg threshold so stablecoins can trip tighter than +LRTs and accruing LRTs can be checked against a per-asset fair value rather than +a flat 1:1 ETH peg. When the market ratio deviates below ``threshold`` of the +asset's ``fair_value``, a CRITICAL alert is raised. -LRT alerts are sent to a single "lrt" protocol channel, each identified by token symbol. -Stablecoin alerts are sent to a "stables" protocol channel. +LRT alerts are sent to the ``lrt`` protocol channel, each identified by token symbol. +Stablecoin alerts are sent to the ``stables`` protocol channel. """ from dataclasses import dataclass @@ -26,12 +28,10 @@ LRT_PROTOCOL = "lrt" STABLES_PROTOCOL = "stables" -# Oracle threshold: any meaningful depeg from fundamental oracle is critical -ORACLE_DEPEG_THRESHOLD = Decimal("0.998") -# DefiLlama threshold: 2% depeg from market price is critical -DEFILLAMA_DEPEG_THRESHOLD = Decimal("0.98") +# Default DefiLlama threshold: alert when market ratio deviates more than 2% below fair value +DEFAULT_DEFILLAMA_THRESHOLD = Decimal("0.98") -# Reference tokens for LRT/BTC ratio computation via DefiLlama +# Reference tokens for LRT/ETH ratio computation via DefiLlama WETH_KEY = "ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" AGGREGATOR_V3_ABI = load_abi("prices/abi/AggregatorV3.json") @@ -48,16 +48,26 @@ class OracleAsset: chain: Chain decimals: int protocol: str + # Asset-specific depeg threshold. Matches the Tenderly alert documented in README. + threshold: Decimal = Decimal("0.998") @dataclass(frozen=True) class DefiLlamaAsset: - """Asset monitored via DefiLlama market price.""" + """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 using a flat 1.0 baseline would miss real depegs + until the price crashed below parity. Per-asset fair values let us catch smaller + deviations from the accrued value. + """ symbol: str defillama_key: str underlying: str # "ETH" or "USD" protocol: str + fair_value: Decimal = Decimal("1.0") + threshold: Decimal = DEFAULT_DEFILLAMA_THRESHOLD # --------------------------------------------------------------------------- @@ -73,36 +83,53 @@ class DefiLlamaAsset: chain=Chain.MAINNET, decimals=8, protocol=LRT_PROTOCOL, + threshold=Decimal("0.998"), ), # cUSD/USD fundamental - Redstone push - # Tenderly alert: 316f440e-457b-4cfa-a69e-f7f54230bf44 + # Tenderly alert 316f440e-457b-4cfa-a69e-f7f54230bf44 fires at latestAnswer < 0.9998. OracleAsset( symbol="cUSD", oracle_address="0x9a5a3c3ed0361505cc1d4e824b3854de5724434a", chain=Chain.MAINNET, decimals=8, protocol=STABLES_PROTOCOL, + threshold=Decimal("0.9998"), ), ] # --------------------------------------------------------------------------- -# DefiLlama-monitored LRT assets (market price vs ETH) -# No on-chain fundamental push oracle available on Ethereum mainnet. -# Redstone provides off-chain fundamental feeds for weETH, ezETH, rsETH, -# pufETH but these are pull-model (not persistent on-chain contracts). +# DefiLlama-monitored LRT assets (market price vs ETH). +# +# No on-chain fundamental push oracle available on Ethereum mainnet. Redstone +# provides off-chain fundamental feeds for weETH/ezETH/rsETH/pufETH but these +# are pull-model (not persistent on-chain contracts). +# +# ``fair_value`` is a conservative floor under the current Redstone fundamental +# (~1.07-1.09 ETH for accruing LRTs). Using 1.0 would mask multi-percent depegs +# from the accrued value; keeping it slightly under the real fundamental avoids +# false alerts from bumpy DefiLlama pricing while still catching real deviations. # --------------------------------------------------------------------------- DEFILLAMA_LRTS: list[DefiLlamaAsset] = [ - DefiLlamaAsset("weETH", "ethereum:0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", "ETH", LRT_PROTOCOL), - DefiLlamaAsset("ezETH", "ethereum:0xbf5495Efe5DB9ce00f80364C8B423567e58d2110", "ETH", LRT_PROTOCOL), - DefiLlamaAsset("rsETH", "ethereum:0xA1290d69c65A6Fe4DF752f95823Fae25cB99e5A7", "ETH", LRT_PROTOCOL), - DefiLlamaAsset("pufETH", "ethereum:0xD9A442856C234a39a81a089C06451EBAa4306a72", "ETH", LRT_PROTOCOL), + DefiLlamaAsset( + "weETH", "ethereum:0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", "ETH", LRT_PROTOCOL, Decimal("1.07") + ), + DefiLlamaAsset( + "ezETH", "ethereum:0xbf5495Efe5DB9ce00f80364C8B423567e58d2110", "ETH", LRT_PROTOCOL, Decimal("1.06") + ), + DefiLlamaAsset( + "rsETH", "ethereum:0xA1290d69c65A6Fe4DF752f95823Fae25cB99e5A7", "ETH", LRT_PROTOCOL, Decimal("1.05") + ), + DefiLlamaAsset( + "pufETH", "ethereum:0xD9A442856C234a39a81a089C06451EBAa4306a72", "ETH", LRT_PROTOCOL, Decimal("1.05") + ), + # No documented off-chain fundamental feed; use 1.0 ETH as a catastrophic-depeg floor. DefiLlamaAsset("osETH", "ethereum:0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38", "ETH", LRT_PROTOCOL), DefiLlamaAsset("rswETH", "ethereum:0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0", "ETH", LRT_PROTOCOL), DefiLlamaAsset("mETH", "ethereum:0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa", "ETH", LRT_PROTOCOL), ] # --------------------------------------------------------------------------- -# DefiLlama-monitored stablecoins (market price vs $1 USD) +# DefiLlama-monitored stablecoins (market price vs $1 USD). # Blue-chip stables (USDC, USDT, DAI) are excluded; they are Tier 1 and # extremely unlikely to depeg. Focus on higher-risk stables. # --------------------------------------------------------------------------- @@ -110,7 +137,15 @@ class DefiLlamaAsset: DefiLlamaAsset("FDUSD", "ethereum:0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409", "USD", STABLES_PROTOCOL), DefiLlamaAsset("deUSD", "ethereum:0x15700B564Ca08D9439C58cA5053166E8317aa138", "USD", STABLES_PROTOCOL), DefiLlamaAsset("USD0", "ethereum:0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5", "USD", STABLES_PROTOCOL), - DefiLlamaAsset("USD0++", "ethereum:0x35D8949372D46B7a3D5A56006AE77B215fc69bC0", "USD", STABLES_PROTOCOL), + # USD0++ is a ~4-year locked bond that legitimately trades at a discount vs USD0. + # Use a looser floor so we only alert on catastrophic dislocation, not normal discount. + DefiLlamaAsset( + "USD0++", + "ethereum:0x35D8949372D46B7a3D5A56006AE77B215fc69bC0", + "USD", + STABLES_PROTOCOL, + threshold=Decimal("0.90"), + ), DefiLlamaAsset("USDe", "ethereum:0x4c9EDD5852cd905f086C759E8383e09bff1E68B3", "USD", STABLES_PROTOCOL), ] @@ -137,26 +172,27 @@ def check_oracle_assets() -> None: logger.error("Expected %d oracle responses, got %d", len(mainnet_assets), len(responses)) return - depegged_lrt: list[tuple[str, Decimal]] = [] - depegged_stables: list[tuple[str, Decimal]] = [] + depegged_lrt: list[tuple[str, Decimal, Decimal]] = [] + depegged_stables: list[tuple[str, Decimal, Decimal]] = [] for asset, result in zip(mainnet_assets, responses): try: # latestRoundData returns (roundId, answer, startedAt, updatedAt, answeredInRound) answer = Decimal(str(result[1])) / Decimal(10**asset.decimals) - logger.info("%s oracle price: %s (threshold: %s)", asset.symbol, answer, ORACLE_DEPEG_THRESHOLD) + logger.info("%s oracle price: %s (threshold: %s)", asset.symbol, answer, asset.threshold) - if answer < ORACLE_DEPEG_THRESHOLD: + if answer < asset.threshold: + entry = (asset.symbol, answer, asset.threshold) if asset.protocol == LRT_PROTOCOL: - depegged_lrt.append((asset.symbol, answer)) + depegged_lrt.append(entry) else: - depegged_stables.append((asset.symbol, answer)) + depegged_stables.append(entry) except Exception 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)) - _send_depeg_alerts(depegged_lrt, LRT_PROTOCOL, "Oracle", ORACLE_DEPEG_THRESHOLD) - _send_depeg_alerts(depegged_stables, STABLES_PROTOCOL, "Oracle", ORACLE_DEPEG_THRESHOLD) + _send_depeg_alerts(depegged_lrt, LRT_PROTOCOL, "Oracle") + _send_depeg_alerts(depegged_stables, STABLES_PROTOCOL, "Oracle") def check_defillama_assets() -> None: @@ -181,38 +217,62 @@ def check_defillama_assets() -> None: send_alert(Alert(AlertSeverity.MEDIUM, "Missing ETH reference price from DefiLlama", LRT_PROTOCOL)) return - depegged_lrt: list[tuple[str, Decimal]] = [] - depegged_stables: list[tuple[str, Decimal]] = [] + depegged_lrt: list[tuple[str, Decimal, Decimal]] = [] + depegged_stables: list[tuple[str, Decimal, Decimal]] = [] + missing_by_protocol: dict[str, list[str]] = {} for asset in all_assets: 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 if asset.underlying == "ETH": - ratio = price / eth_price + market_ratio = price / eth_price else: - ratio = price # Already in USD, peg is $1 - - logger.info("%s price: $%s, ratio vs %s: %s", asset.symbol, price, asset.underlying, ratio) - - if ratio < DEFILLAMA_DEPEG_THRESHOLD: + market_ratio = price # Already in USD, peg is $1 + # Normalize against the asset's fair value so accruing LRTs are checked against + # their accrued rate rather than a flat 1:1 peg. + deviation = market_ratio / asset.fair_value + + logger.info( + "%s price: $%s, %s ratio: %.4f, fair: %s, deviation: %.4f (threshold: %s)", + asset.symbol, + price, + asset.underlying, + market_ratio, + asset.fair_value, + deviation, + asset.threshold, + ) + + if deviation < asset.threshold: + entry = (asset.symbol, deviation, asset.threshold) if asset.protocol == LRT_PROTOCOL: - depegged_lrt.append((asset.symbol, ratio)) + depegged_lrt.append(entry) else: - depegged_stables.append((asset.symbol, ratio)) + depegged_stables.append(entry) + + 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, + ) + ) - _send_depeg_alerts(depegged_lrt, LRT_PROTOCOL, "DefiLlama", DEFILLAMA_DEPEG_THRESHOLD) - _send_depeg_alerts(depegged_stables, STABLES_PROTOCOL, "DefiLlama", DEFILLAMA_DEPEG_THRESHOLD) + _send_depeg_alerts(depegged_lrt, LRT_PROTOCOL, "DefiLlama") + _send_depeg_alerts(depegged_stables, STABLES_PROTOCOL, "DefiLlama") -def _send_depeg_alerts(depegged: list[tuple[str, Decimal]], protocol: str, source: str, threshold: Decimal) -> None: - """Send CRITICAL alert listing all depegged assets.""" +def _send_depeg_alerts(depegged: list[tuple[str, Decimal, Decimal]], protocol: str, source: str) -> None: + """Send CRITICAL alert listing all depegged assets with their per-asset thresholds.""" if not depegged: return - lines = [f"*{symbol}*: {value:.4f}" for symbol, value in depegged] - message = f"Depeg detected ({source}, below {threshold}):\n" + "\n".join(lines) + 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)) From 9df40e7e26774bebd1b2540ac83228ee9a02528a Mon Sep 17 00:00:00 2001 From: spalen0 Date: Mon, 18 May 2026 16:31:30 +0200 Subject: [PATCH 3/3] =?UTF-8?q?prices:=20address=20review=20=E2=80=94=20sh?= =?UTF-8?q?ared=20fetch=5Fprices,=20per-protocol=20routing,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop local DefiLlama client + getcontext() mutation; use shared utils.defillama.fetch_prices. - Merge LRT and stables lists into a single DEFILLAMA_ASSETS; route alerts per asset.protocol (lrt / cap / stables) via grouped dict instead of hardcoded branches. - Drop OracleAsset.threshold default and unused chain field; cUSD oracle now routes to cap channel. - Narrow oracle parse except to (IndexError, TypeError); fetch-failure alerts fan out to every affected protocol; missing-ETH-price no longer aborts USD-side stable checks. - README: warn that accruing-LRT fair_value floors need quarterly bumps; add Channel column; note USDe overlap with stables/main.py. - tests/test_prices.py: 6 tests covering fair-value normalization, missing-price MEDIUM alert, and per-protocol fetch-failure fan-out. Co-Authored-By: Claude Opus 4.7 (1M context) --- prices/README.md | 14 +-- prices/main.py | 244 ++++++++++++++++--------------------------- tests/test_prices.py | 115 ++++++++++++++++++++ 3 files changed, 214 insertions(+), 159 deletions(-) create mode 100644 tests/test_prices.py diff --git a/prices/README.md b/prices/README.md index 803daabd..68f49c5a 100644 --- a/prices/README.md +++ b/prices/README.md @@ -7,14 +7,16 @@ Centralized depeg monitoring for LRTs and stablecoins. Runs two types of checks: When DefiLlama returns no price for a configured asset, a MEDIUM alert fires so coverage gaps are visible rather than silently skipped. -LRT alerts route to the `lrt` Telegram channel. Stablecoin alerts route to `stables`. The workflow exports `TELEGRAM_BOT_TOKEN_LRT`, `TELEGRAM_BOT_TOKEN_STABLES`, `TELEGRAM_CHAT_ID_LRT`, and `TELEGRAM_CHAT_ID_STABLES`; bot tokens fall back to `TELEGRAM_BOT_TOKEN_DEFAULT`, but chat IDs have no fallback and must be set. +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 | Oracle address | Threshold | Tenderly alert | -|-------|----------------|-----------|----------------| -| LBTC | `0xb415eAA355D8440ac7eCB602D3fb67ccC1f0bc81` | 0.998 | `eca272ef-979a-47b3-a7f0-2e67172889bb` (value change between blocks) | -| cUSD | `0x9a5a3c3ed0361505cc1d4e824b3854de5724434a` | 0.9998 | `316f440e-457b-4cfa-a69e-f7f54230bf44` (`latestAnswer` < 0.9998) | +| 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). @@ -44,7 +46,7 @@ Per-asset `fair_value` is a conservative floor under the current Redstone fundam | 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 | | +| 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 diff --git a/prices/main.py b/prices/main.py index dc3b7602..ec12b132 100644 --- a/prices/main.py +++ b/prices/main.py @@ -1,55 +1,45 @@ -"""Depeg monitoring for LRTs and stablecoins. +"""Depeg monitoring via on-chain oracles and DefiLlama market-ratio checks. -Uses Redstone fundamental oracles where available, falls back to DefiLlama pricing. -Each asset carries its own depeg threshold so stablecoins can trip tighter than -LRTs and accruing LRTs can be checked against a per-asset fair value rather than -a flat 1:1 ETH peg. When the market ratio deviates below ``threshold`` of the -asset's ``fair_value``, a CRITICAL alert is raised. +Two signal sources, both routed to the owning protocol's Telegram channel: -LRT alerts are sent to the ``lrt`` protocol channel, each identified by token symbol. -Stablecoin alerts are sent to the ``stables`` protocol 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, getcontext - -from defillama_sdk import DefiLlama +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 -getcontext().prec = 18 - logger = get_logger("prices") -LRT_PROTOCOL = "lrt" -STABLES_PROTOCOL = "stables" - -# Default DefiLlama threshold: alert when market ratio deviates more than 2% below fair value -DEFAULT_DEFILLAMA_THRESHOLD = Decimal("0.98") - -# Reference tokens for LRT/ETH ratio computation via DefiLlama +# Reference token for LRT/ETH ratio computation WETH_KEY = "ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" AGGREGATOR_V3_ABI = load_abi("prices/abi/AggregatorV3.json") -_dl_client = DefiLlama() - @dataclass(frozen=True) class OracleAsset: - """Asset monitored via on-chain Redstone fundamental oracle (AggregatorV3Interface).""" + """Asset monitored via on-chain Redstone fundamental oracle (AggregatorV3).""" symbol: str oracle_address: str - chain: Chain decimals: int protocol: str - # Asset-specific depeg threshold. Matches the Tenderly alert documented in README. - threshold: Decimal = Decimal("0.998") + threshold: Decimal # asset-specific; matches the Tenderly alert documented in README @dataclass(frozen=True) @@ -57,9 +47,9 @@ 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 using a flat 1.0 baseline would miss real depegs - until the price crashed below parity. Per-asset fair values let us catch smaller - deviations from the accrued value. + 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 @@ -67,100 +57,59 @@ class DefiLlamaAsset: underlying: str # "ETH" or "USD" protocol: str fair_value: Decimal = Decimal("1.0") - threshold: Decimal = DEFAULT_DEFILLAMA_THRESHOLD + threshold: Decimal = Decimal("0.98") # 2% deviation from fair_value -# --------------------------------------------------------------------------- -# Oracle-monitored assets (Redstone fundamental push oracles) -# These implement AggregatorV3Interface with latestRoundData() -# --------------------------------------------------------------------------- +# 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 + # LBTC/BTC fundamental — Redstone push, 24h heartbeat / 1% deviation # Tenderly alert: eca272ef-979a-47b3-a7f0-2e67172889bb - OracleAsset( - symbol="LBTC", - oracle_address="0xb415eAA355D8440ac7eCB602D3fb67ccC1f0bc81", - chain=Chain.MAINNET, - decimals=8, - protocol=LRT_PROTOCOL, - threshold=Decimal("0.998"), - ), - # cUSD/USD fundamental - Redstone push - # Tenderly alert 316f440e-457b-4cfa-a69e-f7f54230bf44 fires at latestAnswer < 0.9998. - OracleAsset( - symbol="cUSD", - oracle_address="0x9a5a3c3ed0361505cc1d4e824b3854de5724434a", - chain=Chain.MAINNET, - decimals=8, - protocol=STABLES_PROTOCOL, - threshold=Decimal("0.9998"), - ), -] - -# --------------------------------------------------------------------------- -# DefiLlama-monitored LRT assets (market price vs ETH). -# -# No on-chain fundamental push oracle available on Ethereum mainnet. Redstone -# provides off-chain fundamental feeds for weETH/ezETH/rsETH/pufETH but these -# are pull-model (not persistent on-chain contracts). -# -# ``fair_value`` is a conservative floor under the current Redstone fundamental -# (~1.07-1.09 ETH for accruing LRTs). Using 1.0 would mask multi-percent depegs -# from the accrued value; keeping it slightly under the real fundamental avoids -# false alerts from bumpy DefiLlama pricing while still catching real deviations. -# --------------------------------------------------------------------------- -DEFILLAMA_LRTS: list[DefiLlamaAsset] = [ - DefiLlamaAsset( - "weETH", "ethereum:0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", "ETH", LRT_PROTOCOL, Decimal("1.07") - ), - DefiLlamaAsset( - "ezETH", "ethereum:0xbf5495Efe5DB9ce00f80364C8B423567e58d2110", "ETH", LRT_PROTOCOL, Decimal("1.06") - ), - DefiLlamaAsset( - "rsETH", "ethereum:0xA1290d69c65A6Fe4DF752f95823Fae25cB99e5A7", "ETH", LRT_PROTOCOL, Decimal("1.05") - ), - DefiLlamaAsset( - "pufETH", "ethereum:0xD9A442856C234a39a81a089C06451EBAa4306a72", "ETH", LRT_PROTOCOL, Decimal("1.05") - ), - # No documented off-chain fundamental feed; use 1.0 ETH as a catastrophic-depeg floor. - DefiLlamaAsset("osETH", "ethereum:0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38", "ETH", LRT_PROTOCOL), - DefiLlamaAsset("rswETH", "ethereum:0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0", "ETH", LRT_PROTOCOL), - DefiLlamaAsset("mETH", "ethereum:0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa", "ETH", LRT_PROTOCOL), + 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 stablecoins (market price vs $1 USD). -# Blue-chip stables (USDC, USDT, DAI) are excluded; they are Tier 1 and -# extremely unlikely to depeg. Focus on higher-risk stables. -# --------------------------------------------------------------------------- -DEFILLAMA_STABLES: list[DefiLlamaAsset] = [ - DefiLlamaAsset("FDUSD", "ethereum:0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409", "USD", STABLES_PROTOCOL), - DefiLlamaAsset("deUSD", "ethereum:0x15700B564Ca08D9439C58cA5053166E8317aa138", "USD", STABLES_PROTOCOL), - DefiLlamaAsset("USD0", "ethereum:0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5", "USD", STABLES_PROTOCOL), - # USD0++ is a ~4-year locked bond that legitimately trades at a discount vs USD0. - # Use a looser floor so we only alert on catastrophic dislocation, not normal discount. +# 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_PROTOCOL, + "stables", threshold=Decimal("0.90"), ), - DefiLlamaAsset("USDe", "ethereum:0x4c9EDD5852cd905f086C759E8383e09bff1E68B3", "USD", STABLES_PROTOCOL), + # 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: - """Check assets with on-chain fundamental oracles. Any depeg is CRITICAL.""" - # Group assets by chain for batch requests - mainnet_assets = [a for a in ORACLE_ASSETS if a.chain == Chain.MAINNET] - if not mainnet_assets: + """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 mainnet_assets: + for asset in ORACLE_ASSETS: contract = client.eth.contract( address=client.w3.to_checksum_address(asset.oracle_address), abi=AGGREGATOR_V3_ABI, @@ -168,76 +117,71 @@ def check_oracle_assets() -> None: batch.add(contract.functions.latestRoundData()) responses = client.execute_batch(batch) - if len(responses) != len(mainnet_assets): - logger.error("Expected %d oracle responses, got %d", len(mainnet_assets), len(responses)) + if len(responses) != len(ORACLE_ASSETS): + logger.error("Expected %d oracle responses, got %d", len(ORACLE_ASSETS), len(responses)) return - depegged_lrt: list[tuple[str, Decimal, Decimal]] = [] - depegged_stables: list[tuple[str, Decimal, Decimal]] = [] - - for asset, result in zip(mainnet_assets, responses): + 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) - logger.info("%s oracle price: %s (threshold: %s)", asset.symbol, answer, asset.threshold) - - if answer < asset.threshold: - entry = (asset.symbol, answer, asset.threshold) - if asset.protocol == LRT_PROTOCOL: - depegged_lrt.append(entry) - else: - depegged_stables.append(entry) - except Exception as exc: + 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 - _send_depeg_alerts(depegged_lrt, LRT_PROTOCOL, "Oracle") - _send_depeg_alerts(depegged_stables, STABLES_PROTOCOL, "Oracle") + 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 assets via DefiLlama market prices. 2%+ depeg is CRITICAL.""" - all_assets = DEFILLAMA_LRTS + DEFILLAMA_STABLES - token_keys = list({a.defillama_key for a in all_assets} | {WETH_KEY}) + """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())) - logger.info("Fetching prices for %d tokens from DefiLlama", len(token_keys)) try: - result = _dl_client.prices.getCurrentPrices(token_keys) + prices = fetch_prices(token_keys) except Exception as exc: logger.warning("Failed to fetch DefiLlama prices: %s", exc) - send_alert(Alert(AlertSeverity.LOW, f"Depeg price fetch failed: {exc}", LRT_PROTOCOL)) + # 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 - coins = result.get("coins", {}) - prices = {key: Decimal(str(data["price"])) for key, data in coins.items() if "price" in data} - - eth_price = prices.get(WETH_KEY) - if not eth_price: + 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") - send_alert(Alert(AlertSeverity.MEDIUM, "Missing ETH reference price from DefiLlama", LRT_PROTOCOL)) - return + 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_lrt: list[tuple[str, Decimal, Decimal]] = [] - depegged_stables: list[tuple[str, Decimal, Decimal]] = [] + depegged_by_protocol: dict[str, list[tuple[str, Decimal, Decimal]]] = {} missing_by_protocol: dict[str, list[str]] = {} - for asset in all_assets: + 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 - if asset.underlying == "ETH": - market_ratio = price / eth_price - else: - market_ratio = price # Already in USD, peg is $1 - # Normalize against the asset's fair value so accruing LRTs are checked against - # their accrued rate rather than a flat 1:1 peg. + 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: %.4f, fair: %s, deviation: %.4f (threshold: %s)", + "%s price: $%s, %s ratio: %s, fair: %s, deviation: %s (threshold: %s)", asset.symbol, price, asset.underlying, @@ -248,11 +192,7 @@ def check_defillama_assets() -> None: ) if deviation < asset.threshold: - entry = (asset.symbol, deviation, asset.threshold) - if asset.protocol == LRT_PROTOCOL: - depegged_lrt.append(entry) - else: - depegged_stables.append(entry) + depegged_by_protocol.setdefault(asset.protocol, []).append((asset.symbol, deviation, asset.threshold)) for protocol, symbols in missing_by_protocol.items(): send_alert( @@ -263,14 +203,12 @@ def check_defillama_assets() -> None: ) ) - _send_depeg_alerts(depegged_lrt, LRT_PROTOCOL, "DefiLlama") - _send_depeg_alerts(depegged_stables, STABLES_PROTOCOL, "DefiLlama") + for protocol, depegged in depegged_by_protocol.items(): + _send_depeg_alert(depegged, protocol, "DefiLlama") -def _send_depeg_alerts(depegged: list[tuple[str, Decimal, Decimal]], protocol: str, source: str) -> None: +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.""" - if not depegged: - return 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)) 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()