diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml index fb48c2d..b2c750f 100644 --- a/.github/workflows/daily.yml +++ b/.github/workflows/daily.yml @@ -20,6 +20,7 @@ jobs: scripts: | morpho/governance.py ethena/ethena.py + strata/main.py --profile daily cap/liquidity.py utils/tenderly/tenderly.py yearn/check_shadow_debt.py diff --git a/.github/workflows/hourly.yml b/.github/workflows/hourly.yml index 698a283..d2c9eed 100644 --- a/.github/workflows/hourly.yml +++ b/.github/workflows/hourly.yml @@ -27,11 +27,11 @@ jobs: lrt-pegs/origin_protocol.py euler/markets.py infinifi/main.py + strata/main.py --profile hourly silo/ur_sniff.py usdai/main.py yearn/alert_large_flows.py maple/main.py - timelock/timelock_alerts.py # always run proposals after timelock alerts aave/proposals.py compound/proposals.py diff --git a/.github/workflows/multisig-checker.yml b/.github/workflows/multisig-checker.yml index f6cd6c5..271e58a 100644 --- a/.github/workflows/multisig-checker.yml +++ b/.github/workflows/multisig-checker.yml @@ -16,5 +16,9 @@ jobs: with: cache_file: nonces.txt cache_key_prefix: nonces-v3 + extra_env: | + CACHE_FILENAME=nonces.txt + NONCE_FILENAME=nonces.txt scripts: | safe/main.py + timelock/timelock_alerts.py diff --git a/README.md b/README.md index a427734..511f051 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Monitoring scripts for DeFi protocols to track key metrics and send alerts. - [RTokens - ETH+](./rtoken/README.md) - [Silo](./silo/README.md) - [Spark](./spark/README.md) +- [Strata](./strata/README.md) - [Stargate](./stargate/README.md) - [USD0 - Usual Money](./usd0/README.md) - [USDAI](./usdai/README.md) diff --git a/safe/main.py b/safe/main.py index b6c4b98..7729b24 100644 --- a/safe/main.py +++ b/safe/main.py @@ -138,6 +138,12 @@ "0xd6d4Bcde6c816F17889f1Dd3000aF0261B03a196", "Maple DAO Multisig (syrupUSDC)", ], + [ + "STRATA", + "mainnet", + "0xA27cA9292268ee0f0258B749f1D5740c9Bb68B50", + "Strata Admin Multisig (3/4)", + ], # NOTE: Moonwell multisig monitoring is disabled for now # [ # "MOONWELL", diff --git a/strata/README.md b/strata/README.md new file mode 100644 index 0000000..0559ed4 --- /dev/null +++ b/strata/README.md @@ -0,0 +1,47 @@ +# Strata Monitoring + +Monitors Strata srUSDe/sUSDe risk and governance signals on Ethereum. + +## Scope + +### srUSDe Vault Monitoring +- srUSDe: `0x3d7d6fdf07EE548B939A80edbc9B2256d0cdc003` +- Monitor `convertToAssets(1e18)`; alert if exchange rate decreases. +- Monitor `Deposit` and `Withdraw` events: + - alert for flows `>$1M` + - whale alert for single flow `>$5M` + +### StrataCDO Monitoring +- StrataCDO: `0x908B3921aaE4fC17191D382BB61020f2Ee6C0e20` +- Monitor senior coverage ratio; alert if ratio is below `105%`. +- Watch junior side draining via `jrUSDe.totalAssets()` rapid drop. +- Monitor pausing actions (`setActionStates`) via timelock scheduled-call decoding. + +### Strategy Monitoring +- sUSDeStrategy: `0xdbf4FB6C310C1C85D0b41B5DbCA06096F2E7099F` +- Monitor `sUSDe` balance held by strategy. +- Alert if strategy balance drops significantly relative to total deposits. + +### Governance Monitoring +- Admin Multisig: `0xA27cA9292268ee0f0258B749f1D5740c9Bb68B50` +- 48h Timelock: `0xb2A3CF69C97AFD4dE7882E5fEE120e4efC77B706` +- 24h Timelock: `0x4f2682b78F37910704fB1AFF29358A1da07E022d` + - monitor `CallScheduled`, `CallExecuted`, `Cancelled` + - immediate alert on `CallScheduled` + +### Ethena Dependency Monitoring +- USDe peg: + - warning alert if deviation `>0.5%` + - critical alert if deviation `>2%` +- sUSDe vault anomalies: + - monitor `convertToAssets(1e18)` monotonicity + - monitor cooldown period changes + +## Frequency +- Timelock scheduled calls: near real-time (10-minute cadence, `multisig-checker.yml`) +- Proxy upgrade events: near real-time via safe/timelock queue monitoring (`multisig-checker.yml`) +- srUSDe exchange rate: daily (`daily.yml`) +- Senior coverage ratio: daily (`daily.yml`) +- USDe peg stability: hourly (`hourly.yml`) +- Strategy sUSDe balance: daily (`daily.yml`) +- Protocol TVL changes: daily (`daily.yml`) diff --git a/strata/main.py b/strata/main.py new file mode 100644 index 0000000..b2057c2 --- /dev/null +++ b/strata/main.py @@ -0,0 +1,381 @@ +import argparse +import os +import time + +import requests +from web3 import Web3 + +from utils.abi import load_abi +from utils.cache import cache_filename, get_last_value_for_key_from_file, write_last_value_to_file +from utils.chains import Chain +from utils.logging import get_logger +from utils.telegram import send_telegram_message +from utils.web3_wrapper import ChainManager + +PROTOCOL = "strata" +logger = get_logger(PROTOCOL) + +SRUSDE = Web3.to_checksum_address("0x3d7d6fdf07EE548B939A80edbc9B2256d0cdc003") +JRUSDE = Web3.to_checksum_address("0xC58D044404d8B14e953C115E67823784dEA53d8F") +STRATA_CDO = Web3.to_checksum_address("0x908B3921aaE4fC17191D382BB61020f2Ee6C0e20") +SUSDE = Web3.to_checksum_address("0x9D39A5DE30E57443BfF2A8307A4256c8797A3497") +SUSDE_STRATEGY = Web3.to_checksum_address("0xdbf4FB6C310C1C85D0b41B5DbCA06096F2E7099F") +USDE_COIN_KEY = "ethereum:0x4c9edd5852cd905f086c759e8383e09bff1e68b3" +ENVIO_GRAPHQL_URL = os.getenv("ENVIO_GRAPHQL_URL") + +WEI = 10**18 +REQUEST_TIMEOUT = 15 + +COVERAGE_MIN = 1.05 +USDE_PEG_WARNING = 0.005 +USDE_PEG_CRITICAL = 0.02 +LARGE_FLOW_ALERT_USD = 1_000_000 +WHALE_FLOW_ALERT_USD = 5_000_000 +FLOW_LOOKBACK_SECONDS = 6 * 60 * 60 +STRATEGY_RATIO_DROP_ALERT = 0.20 +TVL_CHANGE_ALERT_RATIO = 0.15 +JR_DRAIN_ALERT_RATIO = 0.15 + +ERC4626_ABI = load_abi("common-abi/YearnV3Vault.json") +ERC20_ABI = load_abi("common-abi/ERC20.json") +SUSDE_COOLDOWN_ABI = [ + { + "inputs": [], + "name": "cooldownDuration", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + } +] + + +def _cache_float(key: str) -> float | None: + value = get_last_value_for_key_from_file(cache_filename, key) + if value == 0: + return None + try: + return float(value) + except ValueError: + return None + + +def _cache_int(key: str) -> int | None: + value = get_last_value_for_key_from_file(cache_filename, key) + if value == 0: + return None + try: + return int(float(value)) + except ValueError: + return None + + +def _set_cache_float(key: str, value: float) -> None: + write_last_value_to_file(cache_filename, key, value) + + +def _breach_once(cache_key: str, condition: bool, message: str, messages: list[str]) -> None: + raw_state = get_last_value_for_key_from_file(cache_filename, cache_key) + state = int(float(raw_state)) if raw_state != 0 else 0 + + if condition: + if state == 0: + messages.append(message) + write_last_value_to_file(cache_filename, cache_key, 1) + elif state == 1: + write_last_value_to_file(cache_filename, cache_key, 0) + + +def _fetch_usde_price() -> float | None: + url = f"https://coins.llama.fi/prices/current/{USDE_COIN_KEY}" + try: + response = requests.get(url, timeout=REQUEST_TIMEOUT) + if response.status_code != 200: + logger.warning("USDe price fetch failed: HTTP %s", response.status_code) + return None + + data = response.json() + return float(data["coins"][USDE_COIN_KEY]["price"]) + except Exception as e: + logger.warning("USDe price fetch failed: %s", e) + return None + + +def _load_sr_flow_events(since_ts: int) -> tuple[list[dict], int] | None: + if not ENVIO_GRAPHQL_URL: + logger.warning("ENVIO_GRAPHQL_URL is not set; skipping srUSDe Deposit/Withdraw monitoring.") + return None + + query = """ + query GetStrataFlows($sinceTs: Int!, $vaultAddress: String!, $chainId: Int!) { + deposits: Deposit( + where: { + vaultAddress: { _eq: $vaultAddress } + chainId: { _eq: $chainId } + blockTimestamp: { _gt: $sinceTs } + } + order_by: { blockTimestamp: asc, blockNumber: asc, logIndex: asc } + limit: 200 + ) { + assets + blockTimestamp + transactionHash + transactionFrom + } + withdrawals: Withdraw( + where: { + vaultAddress: { _eq: $vaultAddress } + chainId: { _eq: $chainId } + blockTimestamp: { _gt: $sinceTs } + } + order_by: { blockTimestamp: asc, blockNumber: asc, logIndex: asc } + limit: 200 + ) { + assets + blockTimestamp + transactionHash + transactionFrom + } + } + """ + variables = {"sinceTs": since_ts, "vaultAddress": SRUSDE.lower(), "chainId": 1} + + try: + response = requests.post( + ENVIO_GRAPHQL_URL, + json={"query": query, "variables": variables}, + timeout=REQUEST_TIMEOUT, + ) + if response.status_code != 200: + logger.warning("Envio flow query failed: HTTP %s", response.status_code) + return None + body = response.json() + if body.get("errors"): + logger.warning("Envio flow query returned errors: %s", body["errors"]) + return None + + events = [] + for event in body.get("data", {}).get("deposits", []): + event["flowType"] = "Deposit" + events.append(event) + for event in body.get("data", {}).get("withdrawals", []): + event["flowType"] = "Withdraw" + events.append(event) + + events.sort(key=lambda event: int(event.get("blockTimestamp", 0))) + max_ts = since_ts + for event in events: + max_ts = max(max_ts, int(event.get("blockTimestamp", since_ts))) + return events, max_ts + except Exception as e: + logger.warning("Envio flow query failed: %s", e) + return None + + +def _check_large_flows(messages: list[str], since_ts: int, usde_price: float | None) -> int | None: + flow_data = _load_sr_flow_events(since_ts) + if flow_data is None: + return None + + events, max_ts = flow_data + usde_reference_price = usde_price if usde_price is not None else 1.0 + for event in events: + assets = float(event["assets"]) / WEI + usd_value = assets * usde_reference_price + if usd_value < LARGE_FLOW_ALERT_USD: + continue + + prefix = "🚨 Whale" if usd_value >= WHALE_FLOW_ALERT_USD else "⚠️ Large" + message = ( + f"{prefix} srUSDe {event['flowType']} detected.\n" + f"Amount: {assets:,.2f} USDe (~${usd_value:,.2f})\n" + f"Tx: {event.get('transactionHash', 'n/a')}" + ) + tx_from = event.get("transactionFrom") + if tx_from: + message += f"\nFrom: {tx_from}" + messages.append(message) + + return max_ts + + +def _check_susde_vault(messages: list[str], client, susde_vault) -> None: + susde_rate_raw = susde_vault.functions.convertToAssets(WEI).call() + susde_rate = float(susde_rate_raw) / WEI + + susde_rate_cache_key = f"{PROTOCOL}_susde_rate" + previous_susde_rate = _cache_float(susde_rate_cache_key) + if previous_susde_rate is not None and susde_rate < previous_susde_rate: + drop_bps = ((previous_susde_rate - susde_rate) / previous_susde_rate) * 10_000 + messages.append( + "🚨 sUSDe vault share value decreased.\n" + f"previous: {previous_susde_rate:.8f} current: {susde_rate:.8f} ({drop_bps:.2f} bps drop)" + ) + _set_cache_float(susde_rate_cache_key, susde_rate) + + try: + cooldown_contract = client.get_contract(SUSDE, SUSDE_COOLDOWN_ABI) + cooldown_duration = int(cooldown_contract.functions.cooldownDuration().call()) + cooldown_cache_key = f"{PROTOCOL}_susde_cooldown_duration" + previous_cooldown = _cache_int(cooldown_cache_key) + if previous_cooldown is not None and cooldown_duration != previous_cooldown: + messages.append( + f"🚨 sUSDe cooldown duration changed.\nprevious: {previous_cooldown}s current: {cooldown_duration}s" + ) + write_last_value_to_file(cache_filename, cooldown_cache_key, cooldown_duration) + except Exception as e: + logger.warning("Could not read sUSDe cooldownDuration: %s", e) + + +def _check_daily_tvl(messages: list[str], total_deposits: float) -> None: + tvl_cache_key = f"{PROTOCOL}_total_deposits" + previous_total_deposits = _cache_float(tvl_cache_key) + if previous_total_deposits is not None and previous_total_deposits > 0: + tvl_change = (total_deposits - previous_total_deposits) / previous_total_deposits + if abs(tvl_change) >= TVL_CHANGE_ALERT_RATIO: + messages.append( + "⚠️ Strata total TVL changed significantly.\n" + f"previous: ${previous_total_deposits:,.2f} current: ${total_deposits:,.2f} ({tvl_change:.2%})" + ) + _set_cache_float(tvl_cache_key, total_deposits) + + +def _check_jr_drain(messages: list[str], jr_assets: float) -> None: + jr_assets_cache_key = f"{PROTOCOL}_jr_assets" + previous_jr_assets = _cache_float(jr_assets_cache_key) + if previous_jr_assets is not None and previous_jr_assets > 0: + jr_change = (jr_assets - previous_jr_assets) / previous_jr_assets + if jr_change <= -JR_DRAIN_ALERT_RATIO: + messages.append( + "⚠️ jrUSDe totalAssets dropped quickly (junior side draining).\n" + f"previous: ${previous_jr_assets:,.2f} current: ${jr_assets:,.2f} ({jr_change:.2%})" + ) + _set_cache_float(jr_assets_cache_key, jr_assets) + + +def main(profile: str) -> None: + client = ChainManager.get_client(Chain.MAINNET) + + sr = client.get_contract(SRUSDE, ERC4626_ABI) + jr = client.get_contract(JRUSDE, ERC4626_ABI) + susde = client.get_contract(SUSDE, ERC20_ABI) + susde_vault = client.get_contract(SUSDE, ERC4626_ABI) + + try: + with client.batch_requests() as batch: + batch.add(sr.functions.totalAssets()) + batch.add(sr.functions.convertToAssets(WEI)) + batch.add(jr.functions.totalAssets()) + batch.add(susde.functions.balanceOf(SUSDE_STRATEGY)) + responses = client.execute_batch(batch) + + if len(responses) != 4: + raise ValueError(f"Batch call expected 4 responses, got {len(responses)}") + + sr_total_assets, sr_rate_raw, jr_total_assets, strategy_raw = responses + + sr_assets = float(sr_total_assets) / WEI + sr_rate = float(sr_rate_raw) / WEI + jr_assets = float(jr_total_assets) / WEI + strategy_susde_balance = float(strategy_raw) / WEI + + coverage_ratio = (sr_assets + jr_assets) / sr_assets if sr_assets > 0 else 0.0 + total_deposits = sr_assets + jr_assets + strategy_ratio = (strategy_susde_balance / total_deposits) if total_deposits > 0 else 0.0 + + logger.info( + "strata_cdo=%s sr_assets=%s sr_rate=%s jr_assets=%s strategy_susde_balance=%s strategy_ratio=%s coverage_ratio=%s", + STRATA_CDO, + f"{sr_assets:,.2f}", + f"{sr_rate:.6f}", + f"{jr_assets:,.2f}", + f"{strategy_susde_balance:,.2f}", + f"{strategy_ratio:.2%}", + f"{coverage_ratio:.4f}", + ) + + messages: list[str] = [] + + if profile in ("all", "daily"): + _breach_once( + f"{PROTOCOL}_coverage_below_105", + coverage_ratio < COVERAGE_MIN, + ( + "🚨 Strata senior coverage ratio below 105%.\n" + f"coverage ratio: {coverage_ratio:.4f} (min {COVERAGE_MIN:.2f})\n" + f"StrataCDO: {STRATA_CDO}" + ), + messages, + ) + + sr_rate_cache_key = f"{PROTOCOL}_sr_rate" + previous_sr_rate = _cache_float(sr_rate_cache_key) + if previous_sr_rate is not None and sr_rate < previous_sr_rate: + drop_bps = ((previous_sr_rate - sr_rate) / previous_sr_rate) * 10_000 + messages.append( + "🚨 srUSDe share value decreased.\n" + f"previous: {previous_sr_rate:.8f} current: {sr_rate:.8f} ({drop_bps:.2f} bps drop)" + ) + _set_cache_float(sr_rate_cache_key, sr_rate) + + if profile in ("all", "hourly"): + usde_price = _fetch_usde_price() + if usde_price is not None: + usde_deviation = abs(usde_price - 1.0) + _breach_once( + f"{PROTOCOL}_usde_peg_critical", + usde_deviation >= USDE_PEG_CRITICAL, + f"🚨 USDe peg is heavily off $1.\nprice: ${usde_price:.4f}, deviation: {usde_deviation:.2%}", + messages, + ) + _breach_once( + f"{PROTOCOL}_usde_peg_warning", + USDE_PEG_WARNING <= usde_deviation < USDE_PEG_CRITICAL, + f"⚠️ USDe peg moved away from $1.\nprice: ${usde_price:.4f}, deviation: {usde_deviation:.2%}", + messages, + ) + else: + usde_price = None + + if profile in ("all", "daily"): + strategy_ratio_cache_key = f"{PROTOCOL}_strategy_ratio" + previous_strategy_ratio = _cache_float(strategy_ratio_cache_key) + if previous_strategy_ratio is not None and previous_strategy_ratio > 0: + strategy_ratio_drop = (previous_strategy_ratio - strategy_ratio) / previous_strategy_ratio + if strategy_ratio_drop >= STRATEGY_RATIO_DROP_ALERT: + messages.append( + "⚠️ sUSDe strategy balance dropped relative to total deposits.\n" + f"previous ratio: {previous_strategy_ratio:.2%} current ratio: {strategy_ratio:.2%} " + f"({strategy_ratio_drop:.2%} drop)" + ) + _set_cache_float(strategy_ratio_cache_key, strategy_ratio) + _check_daily_tvl(messages, total_deposits) + _check_jr_drain(messages, jr_assets) + _check_susde_vault(messages, client, susde_vault) + + if profile in ("all", "daily"): + flow_last_ts_cache_key = f"{PROTOCOL}_last_flow_ts" + flow_since_ts = _cache_int(flow_last_ts_cache_key) + if flow_since_ts is None: + flow_since_ts = int(time.time()) - FLOW_LOOKBACK_SECONDS + flow_max_ts = _check_large_flows(messages, flow_since_ts, usde_price) + if flow_max_ts is not None: + write_last_value_to_file(cache_filename, flow_last_ts_cache_key, flow_max_ts) + + if messages: + send_telegram_message("\n\n".join(messages), PROTOCOL) + + except Exception as e: + logger.error("Error: %s", e) + send_telegram_message(f"⚠️ Strata monitoring failed: {e}", PROTOCOL, False, True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Strata monitoring") + parser.add_argument( + "--profile", + default="all", + choices=["all", "hourly", "daily"], + help="Monitoring profile by cadence.", + ) + args = parser.parse_args() + main(args.profile) diff --git a/timelock/known_selectors.py b/timelock/known_selectors.py index a43d425..96a0aff 100644 --- a/timelock/known_selectors.py +++ b/timelock/known_selectors.py @@ -29,6 +29,9 @@ # Pausable "0x8456cb59": "pause()", "0x3f4ba83a": "unpause()", + # Strata CDO + "0x913db905": "setActionStates(bool,bool,bool)", + "0x7fecc3e5": "setActionStates(uint8,uint8,uint8)", # Governance admin "0xe177246e": "setDelay(uint256)", "0x4dd18bf5": "setPendingAdmin(address)", diff --git a/timelock/timelock_alerts.py b/timelock/timelock_alerts.py index a63e9c3..33c2d33 100644 --- a/timelock/timelock_alerts.py +++ b/timelock/timelock_alerts.py @@ -53,6 +53,8 @@ class TimelockConfig: TimelockConfig("0x3c28b7c7ba1a1f55c9ce66b263b33b204f2126ea", 1, "LRT", "Puffer Timelock"), TimelockConfig("0x2e59a20f205bb85a89c53f1936454680651e618e", 1, "LIDO", "Lido Timelock"), TimelockConfig("0x2efff88747eb5a3ff00d4d8d0f0800e306c0426b", 1, "MAPLE", "Maple GovernorTimelock"), + TimelockConfig("0xb2a3cf69c97afd4de7882e5fee120e4efc77b706", 1, "STRATA", "Strata 48h Timelock"), + TimelockConfig("0x4f2682b78f37910704fb1aff29358a1da07e022d", 1, "STRATA", "Strata 24h Timelock"), # Chain 8453 - Base TimelockConfig("0xf817cb3092179083c48c014688d98b72fb61464f", 8453, "LRT", "superOETH Timelock"), # Yearn Timelock (0x88Ba032be87d5EF1fbE87336B7090767F367BF73) - all chains