diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml new file mode 100644 index 0000000..09fc444 --- /dev/null +++ b/.github/workflows/weekly.yml @@ -0,0 +1,54 @@ +name: Weekly Monitoring Scripts + +on: + schedule: + - cron: "19 8 * * 0" + workflow_dispatch: + +# Add concurrency control +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PROVIDER_URL_MAINNET: ${{ secrets.PROVIDER_URL_MAINNET }} + PROVIDER_URL_MAINNET_1: ${{ secrets.PROVIDER_URL_MAINNET_1 }} + PROVIDER_URL_MAINNET_2: ${{ secrets.PROVIDER_URL_MAINNET_2 }} + PROVIDER_URL_MAINNET_3: ${{ secrets.PROVIDER_URL_MAINNET_3 }} + PROVIDER_URL_POLYGON: ${{ secrets.PROVIDER_URL }} + PROVIDER_URL_POLYGON_1: ${{ secrets.PROVIDER_URL_POLYGON_1 }} + PROVIDER_URL_POLYGON_2: ${{ secrets.PROVIDER_URL_POLYGON_2 }} + PROVIDER_URL_BASE: ${{ secrets.PROVIDER_URL_BASE }} + PROVIDER_URL_BASE_1: ${{ secrets.PROVIDER_URL_BASE_1 }} + PROVIDER_URL_BASE_2: ${{ secrets.PROVIDER_URL_BASE_2 }} + PROVIDER_URL_ARBITRUM: ${{ secrets.PROVIDER_URL_ARBITRUM }} + PROVIDER_URL_KATANA: ${{ secrets.PROVIDER_URL_KATANA }} + PROVIDER_URL_KATANA_1: ${{ secrets.PROVIDER_URL_KATANA_1 }} + TELEGRAM_BOT_TOKEN_DEFAULT: ${{ secrets.TELEGRAM_BOT_TOKEN_DEFAULT }} + TELEGRAM_CHAT_ID_YEARN: ${{ secrets.TELEGRAM_CHAT_ID_YEARN }} + +jobs: + weekly_monitoring: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: "pip" + + - name: Install uv + run: | + python -m pip install --upgrade pip + pip install uv + + - name: Install dependencies + run: | + uv pip install --system . + + - name: Run Yearn endorsed vaults check + run: uv run yearn/check_endorsed.py + env: + GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/yearn/README.md b/yearn/README.md index 6f30a84..ead939b 100644 --- a/yearn/README.md +++ b/yearn/README.md @@ -34,6 +34,32 @@ Optional flags: - `--chain-ids` (default: `1`) - `--no-cache` (disable caching) +======= + +## Endorsed Vault Check + +The script `yearn/check_endorsed.py` verifies that all Yearn v3 vaults listed in the yDaemon API are actually endorsed on-chain in the registry contract. It runs [weekly via GitHub Actions](../.github/workflows/weekly.yml). + +### How It Works + +For each supported chain (Mainnet, Polygon, Base, Arbitrum, Katana): + +1. Fetches all v3 vault addresses from the [yDaemon API](https://ydaemon.yearn.fi). +2. Calls `isEndorsed(address)` on the registry contract (`0xd40ecF29e001c76Dcc4cC0D9cd50520CE845B038`). +3. Collects any vault that is listed in yDaemon but **not** endorsed on-chain. + +### Alerts + +If any unendorsed vaults are found, a Telegram alert is sent to the Yearn group listing each address grouped by chain. If the message exceeds the Telegram character limit, a short summary with a link to the GitHub Actions logs is sent instead. + +### Usage + +```bash +uv run yearn/check_endorsed.py +``` + +======= + ## Timelock Monitoring Yearn TimelockController contracts are monitored across 6 chains via the shared [timelock monitoring script](../timelock/README.md). Alerts are routed to the `YEARN` Telegram channel. @@ -51,19 +77,3 @@ All chains use the same contract address: `0x88ba032be87d5ef1fbe87336b7090767f36 | Katana | [katanascan.com](https://katanascan.com/address/0x88ba032be87d5ef1fbe87336b7090767f367bf73) | | Optimism | [optimistic.etherscan.io](https://optimistic.etherscan.io/address/0x88ba032be87d5ef1fbe87336b7090767f367bf73) | -### Alert Format - -``` -⏰ TIMELOCK: New Operation Scheduled -🅿️ Protocol: YEARN -📋 Timelock: Yearn TimelockController -🔗 Chain: Mainnet -📌 Type: TimelockController -📝 Event: CallScheduled -⏳ Delay: 2d -🎯 Target: 0x1234... -📝 Function: 0xabcdef12 -🔗 Tx: https://etherscan.io/tx/0x... -``` - -For batch operations (`scheduleBatch`), all calls are included in a single message with `--- Call N ---` separators. diff --git a/yearn/check_endorsed.py b/yearn/check_endorsed.py new file mode 100644 index 0000000..fe3f0e7 --- /dev/null +++ b/yearn/check_endorsed.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Verify Yearn v3 yDaemon vaults are endorsed on-chain in the registry. + +Fetches vault metadata from yDaemon per chain and checks each address via +the registry contract's isEndorsed function. Sends a Telegram alert if any +vaults are not endorsed. +""" + +import os +from typing import Dict, List + +import requests +from dotenv import load_dotenv +from web3 import Web3 + +from utils.chains import Chain +from utils.logging import get_logger +from utils.telegram import send_telegram_message +from utils.web3_wrapper import ChainManager + +load_dotenv() + +logger = get_logger("yearn.check_endorsed") + +PROTOCOL = "yearn" + +YDAEMON_BASE_URL = "https://ydaemon.yearn.fi/vaults/v3" +YDAEMON_PARAMS = "hideAlways=true&strategiesDetails=withDetails&strategiesCondition=inQueue" + +REGISTRY_ADDRESS = Web3.to_checksum_address("0xd40ecF29e001c76Dcc4cC0D9cd50520CE845B038") +REGISTRY_ABI = [ + { + "inputs": [{"internalType": "address", "name": "", "type": "address"}], + "name": "isEndorsed", + "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function", + } +] + +CHAINS = [Chain.MAINNET, Chain.POLYGON, Chain.BASE, Chain.ARBITRUM, Chain.KATANA] + + +def fetch_ydaemon_vaults(chain: Chain) -> List[str]: + """Fetch vault addresses from yDaemon API for a given chain. + + Args: + chain: The chain to fetch vaults for. + + Returns: + List of vault addresses. + """ + url = f"{YDAEMON_BASE_URL}?{YDAEMON_PARAMS}&chainIDs={chain.chain_id}" + response = requests.get(url, timeout=30) + response.raise_for_status() + vaults = response.json() + return [vault["address"].lower() for vault in vaults if "address" in vault] + + +def fetch_onchain_endorsed(chain: Chain, addresses: List[str]) -> Dict[str, bool]: + """Batch-fetch the endorsed status for each address from the on-chain registry. + + Args: + chain: The chain to query. + addresses: List of vault addresses to check. + + Returns: + Mapping of address to its endorsed status. + """ + client = ChainManager.get_client(chain) + registry = client.get_contract(REGISTRY_ADDRESS, REGISTRY_ABI) + + with client.batch_requests() as batch: + for addr in addresses: + batch.add(registry.functions.isEndorsed(Web3.to_checksum_address(addr))) + results = batch.execute() + + return dict(zip(addresses, results)) + + +def get_unendorsed(chain: Chain, endorsed_map: Dict[str, bool]) -> List[str]: + """Return addresses that are not endorsed on-chain. + + Args: + chain: The chain (used for logging). + endorsed_map: Mapping of address to endorsed status. + + Returns: + List of unendorsed vault addresses. + """ + unendorsed = [addr for addr, endorsed in endorsed_map.items() if not endorsed] + for addr in unendorsed: + logger.warning("Not endorsed on %s: %s", chain.name, addr) + + logger.info("Chain %s: %d/%d unendorsed", chain.name, len(unendorsed), len(endorsed_map)) + return unendorsed + + +def build_alert_message(errors: Dict[Chain, List[str]], total_checked: int) -> str: + """Build a Telegram alert message from the errors dict. + + Args: + errors: Mapping of chain to list of unendorsed addresses. + total_checked: Total number of vaults checked. + + Returns: + Formatted alert message string. + """ + total_errors = sum(len(addrs) for addrs in errors.values()) + lines = [ + "👹 *yDaemon Endorsed Check*", + f"Checked {total_checked} vaults, found {total_errors} unendorsed:\n", + ] + for chain, addresses in errors.items(): + lines.append(f"*{chain.name}* ({len(addresses)}):") + for addr in addresses: + lines.append(f" `{addr}`") + lines.append("") + return "\n".join(lines) + + +def main() -> None: + """Run the endorsed vault check across all configured chains.""" + logger.info("Starting yDaemon endorsed vault check") + + all_errors: Dict[Chain, List[str]] = {} + total_checked = 0 + + for chain in CHAINS: + logger.info("Checking chain %s (id=%d)", chain.name, chain.chain_id) + try: + addresses = fetch_ydaemon_vaults(chain) + except requests.RequestException as e: + logger.error("Failed to fetch yDaemon data for %s: %s", chain.name, e) + continue + + if not addresses: + logger.info("No vaults found for %s, skipping", chain.name) + continue + + logger.info("Found %d vaults for %s", len(addresses), chain.name) + total_checked += len(addresses) + + endorsed_map = fetch_onchain_endorsed(chain, addresses) + unendorsed = get_unendorsed(chain, endorsed_map) + if unendorsed: + all_errors[chain] = unendorsed + + total_errors = sum(len(addrs) for addrs in all_errors.values()) + logger.info("Done. %d/%d vaults unendorsed", total_errors, total_checked) + + if not all_errors: + logger.info("All vaults endorsed, no alert needed") + return + + message = build_alert_message(all_errors, total_checked) + + # If the message is too long for Telegram, send a short summary with a link to the logs + max_length = 2000 + if len(message) > max_length: + run_url = os.getenv("GITHUB_RUN_URL", "") + if not run_url: + server = os.getenv("GITHUB_SERVER_URL", "https://github.com") + repo = os.getenv("GITHUB_REPOSITORY", "") + run_id = os.getenv("GITHUB_RUN_ID", "") + if repo and run_id: + run_url = f"{server}/{repo}/actions/runs/{run_id}" + + message = ( + f"👹 *yDaemon Endorsed Check*\n" + f"Found {total_errors} unendorsed vaults across {len(all_errors)} chains.\n" + f"Too many to list here." + ) + if run_url: + message += f"\n[Check the full logs]({run_url})" + + send_telegram_message(message, PROTOCOL) + + +if __name__ == "__main__": + main()