diff --git a/automation/jobs.yaml b/automation/jobs.yaml index 892b6b1..79c569e 100644 --- a/automation/jobs.yaml +++ b/automation/jobs.yaml @@ -28,6 +28,7 @@ profiles: - { name: "morpho-markets", script: protocols/morpho/markets.py } - { name: "morpho-markets-v2", script: protocols/morpho/markets_v2.py } - { name: "aave", script: protocols/aave/main.py } + - { name: "spark", script: protocols/spark/main.py } - { name: "lido-steth", script: protocols/lido/steth/main.py } - { name: "lrt-pegs-curve", script: protocols/lrt-pegs/curve/main.py } - { name: "lrt-pegs-fluid", script: protocols/lrt-pegs/fluid/main.py } @@ -49,6 +50,7 @@ profiles: - { name: "compound-proposals", script: protocols/compound/proposals.py } - { name: "fluid-proposals", script: protocols/fluid/proposals.py } - { name: "maker-proposals", script: protocols/maker/proposals.py } + - { name: "spark-proposals", script: protocols/spark/proposals.py } daily: cron: "19 8 * * *" diff --git a/monitoring.yaml b/monitoring.yaml index 9a1e658..f34de64 100644 --- a/monitoring.yaml +++ b/monitoring.yaml @@ -343,6 +343,21 @@ protocols: - name: "Safe Multisig" description: "3/6 multisig monitoring on Mainnet, Optimism, and Arbitrum" + spark: + display_name: "Spark" + description: "SparkLend utilization and Spark governance monitoring" + cadence: "Hourly" + tasks: + - protocols/spark/main.py + - protocols/spark/proposals.py + monitors: + - name: "Utilization" + description: "SparkLend mainnet market utilization above 99%" + - name: "Governance Proposals" + description: "New open proposals in Snapshot space sparkfi.eth (Spark governance portal)" + - name: "DSPause / Timelock" + description: "Tenderly alert on plot() in DSPause covering Spark spells (16-hour execution delay via Sky governance)" + stables: display_name: "Stablecoins" description: "Cross-protocol stablecoin peg, oracle, and large-transfer monitoring" diff --git a/protocols/safe/addresses.py b/protocols/safe/addresses.py index fdaf766..bec5fd7 100644 --- a/protocols/safe/addresses.py +++ b/protocols/safe/addresses.py @@ -63,6 +63,12 @@ ], # aave Governance Guardian Safe ["AAVE", "polygon-main", "0x1A0581dd5C7C3DA4Ba1CDa7e0BcA7286afc4973b"], ["AAVE", "arbitrum-main", "0x1A0581dd5C7C3DA4Ba1CDa7e0BcA7286afc4973b"], + [ + "SPARK", + "mainnet", + "0x44efFc473e81632B12486866AA1678edbb7BEeC3", + "SparkLend Freezer Multisig", + ], [ "MORPHO", "mainnet", diff --git a/protocols/spark/README.md b/protocols/spark/README.md index 2022b3a..c353348 100644 --- a/protocols/spark/README.md +++ b/protocols/spark/README.md @@ -1,9 +1,27 @@ # Spark -**Monitoring - DISABLED** because we don't have any active strategies. - ## Utilization -Github actions run hourly and send telegram message if there is a market with utilization above 95%. [Python script code](https://github.com/yearn/monitoring/blob/main/spark/main.py). +`main.py` checks utilization rates of active, non-deprecated [SparkLend](https://etherscan.io/address/0xC13e21B648A5Ee794902342038FF3aDAB66BE987) markets on mainnet (Aave v3 fork, same logic as `protocols/aave/main.py`): WETH, wstETH, WBTC, USDT, weETH, cbBTC, USDS, PYUSD. Deprecated markets (DAI, sDAI, USDC, sUSDS, LBTC) are excluded. Sends a Telegram alert when a market's utilization is above 99%. + +Run: `python protocols/spark/main.py` + +## Governance + +### Proposals Script + +`proposals.py` monitors new governance proposals from the [Spark governance portal](https://app.spark.fi/spk/governance), which is backed by the Snapshot space [`sparkfi.eth`](https://snapshot.box/#/s:sparkfi.eth) (queried via `https://hub.snapshot.org/graphql`). Sends Telegram alerts for new proposals whose voting is still open and caches the created timestamp of the last processed proposal to avoid duplicate messages. + +Run: `python protocols/spark/proposals.py` + +### Tenderly Alerts + +Spark is a Sky star (subDAO): approved proposals are implemented through the Sky governance cycle. An executive spell is scheduled by calling `plot()` on [DSPause](https://etherscan.io/address/0xbE286431454714F511008713973d3B053A2d38f3) and, after the 16-hour delay, cast through the [PauseProxy](https://etherscan.io/address/0xBE8E3e3618f7474F8cB1d074A26afFef007E98FB), which forwards Spark-related payloads to the [Spark SubDAO Proxy](https://etherscan.io/address/0x3300f198988e4C9C63F75dF86De36421f06af8c4) for execution. + +Tenderly alert on `plot()` called in DSPause covers scheduling of all spells, including Spark ones (this is the same contract already covered by the Maker DSPause alert, see `protocols/maker/README.md`). Note that the PauseProxy (`0xBE8E3e...`) itself has no `plot()` function — only `exec()`. + +To get the proposal data from a received alert: -Possible improvements: add bad debt to risk dao and trigger telegram bot if there is some. Add governance part after the End Game. +1. see the tx from the alert on Tenderly +2. find the spell address in the trace +3. match it with the proposal on the [Spark governance portal](https://app.spark.fi/spk/governance) or [Sky voting portal](https://vote.sky.money/executive) diff --git a/protocols/spark/main.py b/protocols/spark/main.py index 639fcab..361fdd8 100644 --- a/protocols/spark/main.py +++ b/protocols/spark/main.py @@ -1,122 +1,120 @@ -import json +""" +SparkLend monitoring script for tracking utilization rates of assets. +SparkLend is an Aave v3 fork, so utilization is computed the same way as in +protocols/aave/main.py: debt = aToken totalSupply - available underlying. +""" + +from utils.abi import load_abi from utils.alert import Alert, AlertSeverity, send_alert from utils.chains import Chain from utils.logger import get_logger +from utils.telegram import send_error_message from utils.web3_wrapper import ChainManager PROTOCOL = "spark" logger = get_logger(PROTOCOL) -with open("protocols/aave/abi/AToken.json") as f: - abi_data = json.load(f) - if isinstance(abi_data, dict): - abi_atoken = abi_data["result"] - elif isinstance(abi_data, list): - abi_atoken = abi_data - -mainnet_addresses = [ - [ - "0x78f897F0fE2d3B5690EbAe7f19862DEacedF10a7", - "0x83F20F44975D03b1b09e64809B757c47f942BEeA", - ], # asdai, sdai - [ - "0x377C3bd93f2a2984E1E7bE6A5C22c525eD4A4815", - "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - ], # ausdc, usdc - [ - "0x4197ba364AE6698015AE5c1468f54087602715b2", - "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", - ], # awbtc, wbtc - [ - "0x59cD1C87501baa753d0B5B5Ab5D8416A45cD71DB", - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - ], # aweth, weth - [ - "0x12B54025C112Aa61fAce2CDB7118740875A566E9", - "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", - ], # awsteth, wsteth - [ - "0x4DEDf26112B3Ec8eC46e7E31EA5e123490B05B8B", - "0x6B175474E89094C44Da98b954EedeAC495271d0F", - ], # adai, dai - [ - "0xe7dF13b8e3d6740fe17CBE928C7334243d86c92f", - "0xdAC17F958D2ee523a2206206994597C13D831ec7", - ], # ausdt, usdt - [ - "0x9985dF20D7e9103ECBCeb16a84956434B6f06ae8", - "0xae78736Cd615f374D3085123A210448E74Fc6393", - ], # areth, reth - [ - "0xb3973D459df38ae57797811F2A1fd061DA1BC123", - "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", - ], # spcwbtc, cbwbtc - [ - "0x3CFd5C0D4acAA8Faee335842e4f31159fc76B008", - "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", - ], # spweeth, weeth -] - -# TODO: Add different threshold UR's for each asset +ABI_ATOKEN = load_abi("protocols/aave/abi/AToken.json") + +# Active, non-deprecated SparkLend reserves (Pool 0xC13e21B648A5Ee794902342038FF3aDAB66BE987). +# Deprecated markets (DAI, sDAI, USDC, sUSDS, LBTC) and frozen reserves are excluded. +ADDRESSES_BY_CHAIN = { + # spToken, underlying, symbol + Chain.MAINNET: [ + ( + "0x59cD1C87501baa753d0B5B5Ab5D8416A45cD71DB", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "WETH", + ), + ( + "0x12B54025C112Aa61fAce2CDB7118740875A566E9", + "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", + "wstETH", + ), + ( + "0x4197ba364AE6698015AE5c1468f54087602715b2", + "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "WBTC", + ), + ( + "0xe7dF13b8e3d6740fe17CBE928C7334243d86c92f", + "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "USDT", + ), + ( + "0x3CFd5C0D4acAA8Faee335842e4f31159fc76B008", + "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", + "weETH", + ), + ( + "0xb3973D459df38ae57797811F2A1fd061DA1BC123", + "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", + "cbBTC", + ), + ( + "0xC02aB1A5eaA8d1B114EF786D9bde108cD4364359", + "0xdC035D45d973E3EC169d2276DDab16f1e407384F", + "USDS", + ), + ( + "0x779224df1c756b4EDD899854F32a53E8c2B2ce5d", + "0x6c3ea9036406852006290770BEdFcAbA0e23A0e8", + "PYUSD", + ), + ], +} + THRESHOLD_UR = 0.99 -def print_stuff(chain_name, token_name, ur): - logger.info("Chain: %s, Token: %s, UR: %s", chain_name, token_name, ur) +def print_stuff(chain_name: str, token_name: str, ur: float) -> None: + logger.debug(f"Chain: {chain_name}, Token: {token_name}, UR: {ur}") if ur > THRESHOLD_UR: message = f"**BEEP BOP**\nšŸ’Ž Market asset: {token_name}\nšŸ“Š Utilization rate: {ur:.2%}\n🌐 Chain: {chain_name}" send_alert(Alert(AlertSeverity.MEDIUM, message, PROTOCOL)) -# Function to process assets for a specific network -def process_assets(chain, addresses): - # Get Web3 client using ChainManager +def process_assets(chain: Chain) -> None: client = ChainManager.get_client(chain) + addresses = ADDRESSES_BY_CHAIN[chain] # Prepare all contracts and batch calls with client.batch_requests() as batch: - for atoken_address, underlying_token_address in addresses: - atoken = client.eth.contract(address=atoken_address, abi=abi_atoken) - underlying_token = client.eth.contract(address=underlying_token_address, abi=abi_atoken) - - # Add all calls to the batch - batch.add(atoken.functions.totalSupply()) - batch.add(underlying_token.functions.balanceOf(atoken_address)) - batch.add(underlying_token.functions.symbol()) - - # Execute all calls at once - responses = batch.execute() - expected_responses = len(addresses) * 3 + for sptoken_address, underlying_token_address, _ in addresses: + sptoken = client.eth.contract(address=sptoken_address, abi=ABI_ATOKEN) + underlying_token = client.eth.contract(address=underlying_token_address, abi=ABI_ATOKEN) + + batch.add(sptoken.functions.totalSupply()) + batch.add(underlying_token.functions.balanceOf(sptoken_address)) + + responses = client.execute_batch(batch) + expected_responses = len(addresses) * 2 if len(responses) != expected_responses: raise ValueError(f"Expected {expected_responses} responses from batch, got: {len(responses)}") # Process results - expected_responses = len(addresses) * 3 - if len(responses) != expected_responses: - raise ValueError(f"Expected {expected_responses} responses, got: {len(responses)}") + for i, (_, _, token_symbol) in enumerate(addresses): + ts = responses[i * 2] # totalSupply + av = responses[i * 2 + 1] # balanceOf - for i in range(0, len(responses), 3): - ts = responses[i] # totalSupply - av = responses[i + 1] # balanceOf - token_name = responses[i + 2] # symbol - - if None in (ts, av, token_name): - continue - - # Calculate debt and utilization rate debt = ts - av ur = debt / ts if ts != 0 else 0 - print_stuff(chain.name, token_name, ur) + print_stuff(chain.name, token_symbol, ur) -# Main function -def main(): - logger.info("Processing Mainnet assets...") - process_assets(Chain.MAINNET, mainnet_addresses) +def main() -> None: + for chain in [Chain.MAINNET]: + logger.info("Processing %s assets...", chain.name) + try: + process_assets(chain) + except Exception as e: + logger.error("Error processing %s: %s", chain.name, e) + send_error_message(f"Error processing SparkLend assets on {chain.name}: {e}", PROTOCOL) -# Run the main function if __name__ == "__main__": - main() + from utils.runner import run_with_alert + + run_with_alert(main, PROTOCOL) diff --git a/protocols/spark/proposals.py b/protocols/spark/proposals.py new file mode 100644 index 0000000..9391b69 --- /dev/null +++ b/protocols/spark/proposals.py @@ -0,0 +1,120 @@ +"""Monitor new Spark (SPK) governance proposals from the Snapshot space sparkfi.eth. + +Voting happens on https://app.spark.fi/spk/governance, which is backed by the +Snapshot space `sparkfi.eth` (queried via the Snapshot hub GraphQL API). +""" + +from datetime import datetime, timezone + +import requests + +from utils.alert import Alert, AlertSeverity, send_alert +from utils.cache import get_last_queued_id_from_file, write_last_queued_id_to_file +from utils.logger import get_logger +from utils.telegram import escape_markdown, send_error_message + +PROTOCOL = "spark" +logger = get_logger(PROTOCOL) + +SNAPSHOT_GRAPHQL_URL = "https://hub.snapshot.org/graphql" +SNAPSHOT_SPACE = "sparkfi.eth" +GOVERNANCE_URL = "https://app.spark.fi/spk/governance" + +PROPOSALS_QUERY = """ +query Proposals($space: String!) { + proposals( + first: 30 + where: { space_in: [$space] } + orderBy: "created" + orderDirection: desc + ) { + id + title + state + start + end + created + link + } +} +""" + + +def fetch_proposals() -> list[dict]: + """Fetch the most recent proposals from the Snapshot hub GraphQL API.""" + response = requests.post( + SNAPSHOT_GRAPHQL_URL, + json={"query": PROPOSALS_QUERY, "variables": {"space": SNAPSHOT_SPACE}}, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + if payload.get("errors"): + raise ValueError(f"Snapshot GraphQL errors: {payload['errors']}") + proposals: list[dict] = payload["data"]["proposals"] + return proposals + + +def format_timestamp(timestamp: int) -> str: + """Format a unix timestamp as a UTC date-time string.""" + return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + +def get_proposals() -> None: + """Fetch and alert on new Spark governance proposals.""" + try: + proposals = fetch_proposals() + if not proposals: + logger.info("No proposals found for space %s", SNAPSHOT_SPACE) + return + + # Sort by created ascending so we process oldest first. + proposals = sorted(proposals, key=lambda p: p["created"]) + + # Cache stores the created timestamp of the newest processed proposal. + last_reported_timestamp = get_last_queued_id_from_file(PROTOCOL) + newest_timestamp = last_reported_timestamp + + message = "" + for proposal in proposals: + created = int(proposal["created"]) + if created <= last_reported_timestamp: + continue + + if created > newest_timestamp: + newest_timestamp = created + + # Skip proposals whose voting already ended (also avoids a first-run backfill). + if proposal["state"] == "closed": + continue + + message += f"šŸ“• Title: {escape_markdown(proposal['title'])}\n" + message += f"šŸ—³ļø State: {proposal['state']}\n" + message += f"šŸ”— Vote: {proposal['link']}\n" + message += f"ā° Voting ends: {format_timestamp(int(proposal['end']))}\n" + message += "\n" + + if message: + message = f"⚔ Spark Governance Proposals ⚔\nšŸ›ļø Portal: {GOVERNANCE_URL}\n\n" + message + send_alert(Alert(AlertSeverity.LOW, message, PROTOCOL)) + else: + logger.info("No new open proposals to report") + + if newest_timestamp > last_reported_timestamp: + write_last_queued_id_to_file(PROTOCOL, newest_timestamp) + logger.info("Processed proposals up to timestamp %s", newest_timestamp) + + except requests.RequestException as e: + error_message = f"Failed to fetch Spark proposals from Snapshot: {escape_markdown(str(e))}" + logger.error("%s", error_message) + send_error_message(error_message, PROTOCOL) + except Exception as e: + error_message = f"Error processing Spark proposals: {escape_markdown(str(e))}" + logger.error("%s", error_message) + send_error_message(error_message, PROTOCOL) + + +if __name__ == "__main__": + from utils.runner import run_with_alert + + run_with_alert(get_proposals, PROTOCOL)