Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/weekly.yml
Original file line number Diff line number Diff line change
@@ -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 }}
42 changes: 26 additions & 16 deletions yearn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
181 changes: 181 additions & 0 deletions yearn/check_endorsed.py
Original file line number Diff line number Diff line change
@@ -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()