From f767695e21ceb643e83db4904751d7ef5176ab39 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 31 Mar 2026 10:26:07 +0200 Subject: [PATCH] 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", ]