diff --git a/.github/readme-images/ens.png b/.github/readme-images/ens.png deleted file mode 100644 index b32d5344..00000000 Binary files a/.github/readme-images/ens.png and /dev/null differ diff --git a/.github/readme-images/minipools.png b/.github/readme-images/minipools.png deleted file mode 100644 index 456b8ab8..00000000 Binary files a/.github/readme-images/minipools.png and /dev/null differ diff --git a/.github/readme-images/odao_members.png b/.github/readme-images/odao_members.png deleted file mode 100644 index a298aa1e..00000000 Binary files a/.github/readme-images/odao_members.png and /dev/null differ diff --git a/.github/readme-images/pool.png b/.github/readme-images/pool.png deleted file mode 100644 index 8e373c04..00000000 Binary files a/.github/readme-images/pool.png and /dev/null differ diff --git a/.github/readme-images/proposals.png b/.github/readme-images/proposals.png deleted file mode 100644 index 4ea784b9..00000000 Binary files a/.github/readme-images/proposals.png and /dev/null differ diff --git a/.github/renovate.json b/.github/renovate.json index bbca9d87..abed150b 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,23 +1,19 @@ { + "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ - "config:base" + "config:recommended" ], - "patch": { - "automerge": true - }, - "pin": { - "automerge": true - }, - "rollback": { - "automerge": true - }, - "docker-compose": { - "automerge": false - }, - "docker": { - "automerge": false - }, - "prCreation": "not-pending", + "forkProcessing": "enabled", "rollbackPrs": true, - "stabilityDays": 3 + "stabilityDays": 3, + "packageRules": [ + { + "matchUpdateTypes": ["patch", "pin", "rollback"], + "automerge": true + }, + { + "matchManagers": ["dockerfile", "docker-compose"], + "automerge": false + } + ] } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..d9bec277 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,38 @@ +name: Build + +on: + push: + branches: [ main ] + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Force HTTPS for Git submodules + run: | + git config --global url."https://github.com/".insteadOf git@github.com: + git config --global url."https://github.com/".insteadOf ssh://git@github.com/ + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Login to DockerHub + uses: docker/login-action@v4 + with: + username: haloooloolo + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: rocketwatch/Dockerfile + push: true + tags: haloooloolo/rocketwatch:latest + no-cache: true + platforms: linux/amd64 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index f8e03d00..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,71 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ main ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] - schedule: - - cron: '26 9 * * 3' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2.2.6 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2.2.6 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.2.6 diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml deleted file mode 100644 index 27cc2a4a..00000000 --- a/.github/workflows/docker-ci.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: ci - -on: - push: - branches: - - 'main' - -jobs: - docker: - runs-on: ubuntu-latest - steps: - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to DockerHub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v4 - with: - context: "{{defaultContext}}:rocketwatch" - push: true - tags: invisiblesymbol/rocketwatch:latest - no-cache: true - platforms: linux/amd64 - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..b22fd578 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,28 @@ +name: Lint + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/ruff-action@v3 + with: + args: "check" + src: "rocketwatch" + - uses: astral-sh/ruff-action@v3 + with: + args: "format --check" + src: "rocketwatch" + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + - run: uv sync --extra dev + - run: uv run mypy rocketwatch/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..d526c1ce --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + - run: uv run --python 3.14 --extra test pytest --cov=rocketwatch --cov-report=term-missing --cov-report=xml + - uses: codecov/codecov-action@v5 + with: + files: coverage.xml diff --git a/.gitignore b/.gitignore index 510dae18..b3e96b42 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,6 @@ sdist/ var/ wheels/ *.egg-info/ -.installed.cfg *.egg MANIFEST @@ -110,13 +109,17 @@ venv.bak/ .dmypy.json dmypy.json -# Pyre type checker -.pyre/ - -# Pycharm project stuff +# IDE files .idea/ +.vscode # state state.db -*/main.cfg -mongodb/ \ No newline at end of file +*/config.toml +mongodb/ + +# helper scripts +*.sh + +.claude +uv.lock diff --git a/.gitmodules b/.gitmodules index f04a9117..d9ac7a15 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "rocketwatch/contracts/rocketpool"] path = rocketwatch/contracts/rocketpool url = https://github.com/rocket-pool/rocketpool - branch = houston + branch = v1.4 diff --git a/.pep8speaks.yml b/.pep8speaks.yml deleted file mode 100644 index a99bdf94..00000000 --- a/.pep8speaks.yml +++ /dev/null @@ -1,17 +0,0 @@ -scanner: - diff_only: True - linter: pycodestyle - -pycodestyle: - max-line-length: 128 - ignore: - - E261 - - E501 - - W605 - - E111 - - E114 - - E231 - - E702 - - E203 - -no_blank_comment: False \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..2ca87691 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.5 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: local + hooks: + - id: pytest + name: pytest + entry: uv run pytest -x -q + language: system + pass_filenames: false + always_run: true diff --git a/README.md b/README.md index d67a0443..e6162ebc 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,178 @@ # Rocket Watch -A Discord bot that tracks Rocket Pool Events - -[![wakatime](https://wakatime.com/badge/github/InvisibleSymbol/rocketwatch.svg)](https://wakatime.com/badge/github/InvisibleSymbol/rocketwatch) - -- Ability to track Proposals (Description/Vote Count read from Contract) -- Ability to track oDAO Member Activity (uses Nicknames of oDAO Members if available) -- Ability to track Deposit Poll Activity -- Ability to track Minipool Activity (Provides Link to Validator if feasible) -- Supports ENS Addresses -- Automatically retrieves Addresses from Storage Contract at start-up. (Easy support for Upgrades) -- Supports dual-channel setup to separate oDAO Events from the rest. -- Deduplication-Logic (prevents duplicated Messages caused by Chain-Reorgs). -- Easy Extendability (Almost no hard-coded Events, most are loaded from a `.json` File) - -## Donate: -[0xinvis.eth](https://etherscan.io/address/0xf0138d2e4037957d7b37de312a16a88a7f83a32a) +[![Test](https://github.com/haloooloolo/rocketwatch/actions/workflows/test.yml/badge.svg)](https://github.com/haloooloolo/rocketwatch/actions/workflows/test.yml) +[![codecov](https://codecov.io/gh/haloooloolo/rocketwatch/graph/badge.svg)](https://codecov.io/gh/haloooloolo/rocketwatch) +![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue) + +A Discord bot that monitors and reports on [Rocket Pool](https://rocketpool.net) protocol activity across the Ethereum execution and consensus layers. + +## Features + +- **On-chain event tracking**: monitors Rocket Pool smart contract events (deposits, minipools, rewards, governance votes, etc.) and posts formatted embeds to Discord +- **Beacon chain integration**: tracks validator proposals, sync committees, and consensus layer activity +- **Governance monitoring**: follows on-chain DAO votes (pDAO, oDAO, Security Council) and Snapshot proposals +- **Data visualization**: generates APR charts, collateral distributions, fee breakdowns, and TVL calculations using matplotlib +- **ENS resolution**: resolves and caches ENS names for readable address display +- **Multi-channel support**: split event tracking and status messages across multiple channels +- **Deduplication**: prevents duplicate messages caused by chain reorgs or bot restarts +- **Dynamic contract loading**: retrieves contract addresses from the Rocket Pool storage contract at startup, automatically supporting protocol upgrades +- **Plugin system**: 40+ plugins that can be individually enabled or disabled + +## Architecture + +``` +rocketwatch/ +├── __main__.py # Entry point +├── rocketwatch.py # Bot class, plugin loader, error handling +├── config.toml.sample # Configuration template +├── Dockerfile +├── plugins/ # 40+ plugin modules +│ ├── event_core/ # Main event tracking logic +│ ├── dao/ # On-chain governance +│ ├── snapshot/ # Off-chain governance +│ ├── apr/ # APR calculations & charts +│ ├── rewards/ # Reward estimation +│ ├── tvl/ # Total Value Locked +│ ├── proposals/ # Block proposals +│ └── ... +└── utils/ + ├── config.py # Pydantic config models + ├── rocketpool.py # Contract interface with caching + ├── shared_w3.py # Web3 client instances + ├── embeds.py # Discord embed formatting + ├── solidity.py # Unit conversions + ├── readable.py # Human-readable formatting + └── ... +``` + +## Prerequisites + +- Python 3.12+ +- MongoDB 8.x +- Ethereum execution and consensus layer RPC endpoints +- Discord bot token + +## Setup + +### Configuration + +Copy the sample config and fill in your values: + +```sh +cp rocketwatch/config.toml.sample rocketwatch/config.toml +``` + +Key configuration sections: + +| Section | Purpose | +|---|---| +| `discord` | Bot token, owner/server IDs, channel mappings | +| `execution_layer` | RPC endpoints (current, mainnet, archive) and Etherscan API key | +| `consensus_layer` | Beacon API endpoint and beaconcha.in API key | +| `mongodb` | Database connection URI | +| `rocketpool` | Chain, contract addresses, DAO multisigs, support settings | +| `modules` | Plugin include/exclude lists | +| `events` | Event tracking setup | + +### Docker (recommended) + +```sh +docker compose up -d +``` + +This starts the bot, MongoDB, and [Watchtower](https://containrrr.dev/watchtower/) for automatic updates. + +### Manual + +```sh +# Install uv (https://docs.astral.sh/uv/) +cd rocketwatch +uv run . +``` + +## Development + +### Linting + +```sh +uv run ruff check rocketwatch/ +``` + +Configured rules: `B` (bugbear), `E` (pycodestyle), `F` (pyflakes), `I` (isort), `RUF`, `SIM`, `UP` (pyupgrade), `W` (warnings). + +### Type checking + +```sh +uv run mypy rocketwatch/ +``` + +### Testing + +```sh +uv run --extra test pytest +``` + +### Plugin structure + +Each plugin lives in `rocketwatch/plugins//` and follows this pattern: + +```python +from discord.ext import commands + +class MyPlugin(commands.Cog): + def __init__(self, bot): + self.bot = bot + + # slash commands, event listeners, background tasks, etc. + +async def setup(bot): + await bot.add_cog(MyPlugin(bot)) +``` + +Plugins that track events extend `EventPlugin` from [`utils/event.py`](rocketwatch/utils/event.py) and implement the `_get_new_events()` method, which is called periodically to check for new events. They may also override `get_past_events()` to support querying historical events for a given block range: + +```python +from utils.event import Event, EventPlugin +from utils.embeds import Embed + +class MyEventPlugin(EventPlugin): + async def _get_new_events(self) -> list[Event]: + events = [] + # query contracts, APIs, etc. + embed = Embed(title="My Event") + events.append(Event( + embed=embed, + topic="my_topic", + event_name="my_event", + unique_id="some_unique_id", + block_number=block_number, + )) + return events +``` + +Plugins that provide a rotating status embed (displayed by the bot when idle) extend `StatusPlugin` from [`utils/status.py`](rocketwatch/utils/status.py) and implement the `get_status()` method: + +```python +from utils.status import StatusPlugin +from utils.embeds import Embed + +class MyStatusPlugin(StatusPlugin): + async def get_status(self) -> Embed: + embed = Embed(title="My Status") + embed.add_field(name="Info", value="...") + return embed +``` + +Plugins can be selectively loaded via the `modules.include` / `modules.exclude` config fields. + +## CI/CD + +| Workflow | Trigger | Purpose | +|---|---|---| +| [Lint](.github/workflows/lint.yml) | Push & PR to main | Ruff linting & mypy type checking | +| [Test](.github/workflows/test.yml) | Push & PR to main | pytest suite | +| [Build](.github/workflows/build.yml) | Push to main | Build & push image to DockerHub | + +## License + +[GNU General Public License v3](LICENSE) diff --git a/compose.yaml b/compose.yaml index 2edd5fcd..6793f86f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,10 +1,12 @@ services: rocketwatch: - image: invisiblesymbol/rocketwatch - build: ./rocketwatch + image: haloooloolo/rocketwatch + build: + context: . + dockerfile: rocketwatch/Dockerfile volumes: - ./rocketwatch/contracts/rocketpool:/app/contracts/rocketpool - - ./rocketwatch/main.cfg:/app/main.cfg + - ./rocketwatch/config.toml:/app/config.toml restart: unless-stopped depends_on: - mongodb @@ -17,7 +19,7 @@ services: com.centurylinklabs.watchtower.enable: true mongodb: - image: mongo:6.0.5 + image: mongo:8.2.6 volumes: - ./mongodb:/data/db restart: unless-stopped @@ -31,7 +33,7 @@ services: - "127.0.0.1:27017:27017" watchtower: - image: containrrr/watchtower + image: nickfedor/watchtower:latest volumes: - /var/run/docker.sock:/var/run/docker.sock command: --interval 30 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..c6c4fec2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +[project] +name = "rocketwatch" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "aiohttp==3.13.3", + "aiocache==0.12.3", + "anyascii==0.3.3", + "beautifulsoup4==4.14.3", + "bidict==0.23.1", + "cachetools==7.0.5", + "colorama==0.4.6", + "cronitor==4.9.0", + "dice==4.0.0", + "discord.py==2.7.1", + "eth-typing==5.2.1", + "eth-utils==5.3.1", + "etherscan_labels @ git+https://github.com/haloooloolo/etherscan-labels", + "graphql_query==1.4.0", + "hexbytes==1.3.1", + "humanize==4.15.0", + "inflect==7.5.0", + "matplotlib==3.10.8", + "numpy==2.4.3", + "pillow==12.1.1", + "psutil==7.2.2", + "pydantic>=2.0.0,<3.0.0", + "pymongo==4.16.0", + "python_i18n==0.3.9", + "pytz==2026.1.post1", + "regex==2026.2.28", + "retry-async==0.1.4", + "seaborn==0.13.2", + "tabulate==0.10.0", + "termplotlib==0.3.9", + "tiktoken==0.12.0", + "uptime==3.0.1", + "web3>=7.0.0,<8.0.0", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", + "pytest-asyncio>=1.0", + "pytest-cov>=6.0", +] +dev = [ + "mypy>=1.10", + "types-cachetools>=5.0", + "types-pytz>=2024.0", + "types-tabulate>=0.9", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.12" +mypy_path = "." +explicit_package_bases = true +ignore_missing_imports = true +check_untyped_defs = true +warn_return_any = false +warn_unused_ignores = true +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[[tool.mypy.overrides]] +module = [ + "rocketwatch.plugins.events.*", + "rocketwatch.plugins.transactions.*", +] +ignore_errors = true + +[tool.ruff] +target-version = "py312" + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "RUF", "SIM", "UP", "W"] +ignore = ["E501", "E203", "E231"] + +[tool.ruff.lint.isort] +known-first-party = ["utils", "plugins", "strings", "rocketwatch"] diff --git a/rocketwatch/Dockerfile b/rocketwatch/Dockerfile index 01e8839a..f1fc3bb2 100644 --- a/rocketwatch/Dockerfile +++ b/rocketwatch/Dockerfile @@ -1,12 +1,12 @@ # syntax=docker/dockerfile:1 -FROM python:3.10.8 +FROM python:3.14.3 -COPY requirements.txt requirements.txt -RUN pip install --upgrade pip -RUN pip install -r requirements.txt +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv -COPY . /app +COPY pyproject.toml pyproject.toml +RUN uv pip install --system --no-cache -r pyproject.toml + +COPY rocketwatch/ /app ENV PYTHONUNBUFFERED=1 -ENV MULTICALL_PROCESSES=11 WORKDIR /app CMD [ "python", "." ] diff --git a/rocketwatch/__init__.pyi b/rocketwatch/__init__.pyi new file mode 100644 index 00000000..38046386 --- /dev/null +++ b/rocketwatch/__init__.pyi @@ -0,0 +1 @@ +from rocketwatch.rocketwatch import RocketWatch as RocketWatch diff --git a/rocketwatch/__main__.py b/rocketwatch/__main__.py index 56be7efa..7d0e04c4 100644 --- a/rocketwatch/__main__.py +++ b/rocketwatch/__main__.py @@ -2,15 +2,16 @@ from discord import Intents -from utils.cfg import cfg from rocketwatch import RocketWatch +from utils.config import cfg -logging.basicConfig(format="%(levelname)5s %(asctime)s [%(name)s] %(filename)s:%(lineno)d|%(funcName)s(): %(message)s") +logging.basicConfig( + format="%(levelname)5s %(asctime)s [%(name)s] %(filename)s:%(lineno)d|%(funcName)s(): %(message)s" +) logging.getLogger().setLevel("INFO") -logging.getLogger("discord.client").setLevel(cfg["log_level"]) +logging.getLogger("rocketwatch").setLevel(cfg.log_level) -log = logging.getLogger("discord_bot") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.main") def main() -> None: @@ -24,7 +25,7 @@ def main() -> None: log.info("Starting bot...") bot = RocketWatch(intents=intents) - bot.run(cfg["discord.secret"]) + bot.run(cfg.discord.secret) if __name__ == "__main__": diff --git a/rocketwatch/config.toml.sample b/rocketwatch/config.toml.sample new file mode 100644 index 00000000..bbe3862a --- /dev/null +++ b/rocketwatch/config.toml.sample @@ -0,0 +1,88 @@ +log_level = "INFO" + +[discord] +secret = "" + +[discord.owner] +user_id = -1 +server_id = -1 + +[discord.channels] +default = -1 +dao = -1 +errors = -1 + +[execution_layer] +explorer = "https://etherscan.io" +etherscan_secret = "" + +[execution_layer.endpoint] +current = "http://node:8545" +mainnet = "http://node:8545" +archive = "http://node:8545" + +[consensus_layer] +explorer = "https://beaconcha.in" +endpoint = "http://node:5052" +beaconcha_secret = "" + +[mongodb] +uri = "mongodb://mongodb:27017" + +[rocketpool] +chain = "mainnet" +dao_multisigs = [ + "0x778c08fC151D7AB10042334B6A0929D4fa2983cA", + "0x6efD08303F42EDb68F2D6464BCdCA0824e1C813a", + "0xb867EA3bBC909954d737019FEf5AB25dFDb38CB9", +] + +[rocketpool.support] +user_ids = [] +role_ids = [] +server_id = -1 +channel_id = -1 +moderator_id = -1 + +[rocketpool.dm_warning] +channels = [] + +[rocketpool.manual_addresses] +rocketStorage = "0x1d8f8f00cfa6758d7bE78336684788Fb0ee0Fa46" +rocketSignerRegistry = "0xc1062617d10Ae99E09D941b60746182A87eAB38F" +rocketExitArbitrage = "0x2631618408497d27D455aBA9c99A6f61eF305559" +multicall3 = "0xcA11bde05977b3631167028862bE2a173976CA11" +AirSwap = "0x4572f2554421Bd64Bef1c22c8a81840E8D496BeA" +yearnPool = "0x5c0A86A32c129538D62C106Eb8115a8b02358d57" +curvePool = "0x447Ddd4960d9fdBF6af9a790560d0AF76795CB08" +wstETHToken = "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0" +unstETH = "0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1" +ConstellationDirectory = "0x4343743dBc46F67D3340b45286D8cdC13c8575DE" +LUSD = "0x5f98805A4E8be255a32880FDeC7F6728C6568bA0" +BalancerVault = "0xBA12222222228d8Ba445958a75a0704d566BF2C8" +UniV3_USDC_ETH = "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640" +UniV3_rETH_ETH = "0x553e9C493678d8606d6a5ba284643dB2110Df823" +RockSolidVault = "0x936faCdf10c8c36294e7b9d28345255539d81bc7" +GPv2Settlement = "0x9008D19f58AAbD9eD0D60971565AA8510560ab41" + +[modules] +include = [] +exclude = [] +enable_commands = true + +[events] +lookback_distance = 8 +genesis = 13325233 +block_batch_size = 1000 + +[events.status_message.default] +plugin = "DepositPool" +cooldown = 60 +fields = [] + +[other] +mev_hashes = [] + +[other.secrets] +wakatime = "" +cronitor = "" diff --git a/rocketwatch/contracts/GPv2Settlement.abi.json b/rocketwatch/contracts/GPv2Settlement.abi.json new file mode 100644 index 00000000..451d6a85 --- /dev/null +++ b/rocketwatch/contracts/GPv2Settlement.abi.json @@ -0,0 +1,51 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "contract IERC20", + "name": "sellToken", + "type": "address" + }, + { + "indexed": false, + "internalType": "contract IERC20", + "name": "buyToken", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "sellAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "buyAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "feeAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "orderUid", + "type": "bytes" + } + ], + "name": "Trade", + "type": "event" + } +] diff --git a/rocketwatch/contracts/RockSolidVault.abi.json b/rocketwatch/contracts/RockSolidVault.abi.json new file mode 100644 index 00000000..f628a39c --- /dev/null +++ b/rocketwatch/contracts/RockSolidVault.abi.json @@ -0,0 +1,2466 @@ +[ + { + "inputs": [ + { + "internalType": "bool", + "name": "disable", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "maxRate", + "type": "uint256" + } + ], + "name": "AboveMaxRate", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "name": "AddressEmptyCode", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "AddressInsufficientBalance", + "type": "error" + }, + { + "inputs": [], + "name": "CantDepositNativeToken", + "type": "error" + }, + { + "inputs": [], + "name": "Closed", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "max", + "type": "uint256" + } + ], + "name": "ERC4626ExceededMaxDeposit", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "max", + "type": "uint256" + } + ], + "name": "ERC4626ExceededMaxMint", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "max", + "type": "uint256" + } + ], + "name": "ERC4626ExceededMaxRedeem", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "max", + "type": "uint256" + } + ], + "name": "ERC4626ExceededMaxWithdraw", + "type": "error" + }, + { + "inputs": [], + "name": "ERC7540InvalidOperator", + "type": "error" + }, + { + "inputs": [], + "name": "ERC7540PreviewDepositDisabled", + "type": "error" + }, + { + "inputs": [], + "name": "ERC7540PreviewMintDisabled", + "type": "error" + }, + { + "inputs": [], + "name": "ERC7540PreviewRedeemDisabled", + "type": "error" + }, + { + "inputs": [], + "name": "ERC7540PreviewWithdrawDisabled", + "type": "error" + }, + { + "inputs": [], + "name": "EnforcedPause", + "type": "error" + }, + { + "inputs": [], + "name": "ExpectedPause", + "type": "error" + }, + { + "inputs": [], + "name": "FailedInnerCall", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidInitialization", + "type": "error" + }, + { + "inputs": [], + "name": "MathOverflowedMulDiv", + "type": "error" + }, + { + "inputs": [], + "name": "NewTotalAssetsMissing", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "enum State", + "name": "currentState", + "type": "uint8" + } + ], + "name": "NotClosing", + "type": "error" + }, + { + "inputs": [], + "name": "NotInitializing", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "enum State", + "name": "currentState", + "type": "uint8" + } + ], + "name": "NotOpen", + "type": "error" + }, + { + "inputs": [], + "name": "NotWhitelisted", + "type": "error" + }, + { + "inputs": [], + "name": "OnlyAsyncDepositAllowed", + "type": "error" + }, + { + "inputs": [], + "name": "OnlyOneRequestAllowed", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "safe", + "type": "address" + } + ], + "name": "OnlySafe", + "type": "error" + }, + { + "inputs": [], + "name": "OnlySyncDepositAllowed", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "valuationManager", + "type": "address" + } + ], + "name": "OnlyValuationManager", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "whitelistManager", + "type": "address" + } + ], + "name": "OnlyWhitelistManager", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "RequestIdNotClaimable", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "RequestNotCancelable", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "inputs": [], + "name": "ValuationUpdateNotAllowed", + "type": "error" + }, + { + "inputs": [], + "name": "WrongNewTotalAssets", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "Deposit", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "controller", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "name": "DepositRequest", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "DepositRequestCanceled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "DepositSync", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "oldReceiver", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newReceiver", + "type": "address" + } + ], + "name": "FeeReceiverUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "oldHighWaterMark", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newHighWaterMark", + "type": "uint256" + } + ], + "name": "HighWaterMarkUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint64", + "name": "version", + "type": "uint64" + } + ], + "name": "Initialized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "totalAssets", + "type": "uint256" + } + ], + "name": "NewTotalAssetsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "controller", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "OperatorSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferStarted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Paused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "components": [ + { + "internalType": "uint16", + "name": "managementRate", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "performanceRate", + "type": "uint16" + } + ], + "indexed": false, + "internalType": "struct Rates", + "name": "oldRates", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint16", + "name": "managementRate", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "performanceRate", + "type": "uint16" + } + ], + "indexed": false, + "internalType": "struct Rates", + "name": "newRate", + "type": "tuple" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "timestamp", + "type": "uint256" + } + ], + "name": "RatesUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "controller", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "RedeemRequest", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "referral", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "name": "Referral", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint40", + "name": "epochId", + "type": "uint40" + }, + { + "indexed": true, + "internalType": "uint40", + "name": "settledId", + "type": "uint40" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "totalAssets", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "totalSupply", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assetsDeposited", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "sharesMinted", + "type": "uint256" + } + ], + "name": "SettleDeposit", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint40", + "name": "epochId", + "type": "uint40" + }, + { + "indexed": true, + "internalType": "uint40", + "name": "settledId", + "type": "uint40" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "totalAssets", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "totalSupply", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assetsWithdrawed", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "sharesBurned", + "type": "uint256" + } + ], + "name": "SettleRedeem", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "enum State", + "name": "state", + "type": "uint8" + } + ], + "name": "StateUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint128", + "name": "oldLifespan", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "newLifespan", + "type": "uint128" + } + ], + "name": "TotalAssetsLifespanUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "totalAssets", + "type": "uint256" + } + ], + "name": "TotalAssetsUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "Unpaused", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "oldManager", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newManager", + "type": "address" + } + ], + "name": "ValuationManagerUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "WhitelistDisabled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "oldManager", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newManager", + "type": "address" + } + ], + "name": "WhitelistManagerUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "authorized", + "type": "bool" + } + ], + "name": "WhitelistUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "Withdraw", + "type": "event" + }, + { + "inputs": [], + "name": "MAX_MANAGEMENT_RATE", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_PERFORMANCE_RATE", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MAX_PROTOCOL_RATE", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "acceptOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "accounts", + "type": "address[]" + } + ], + "name": "addToWhitelist", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "asset", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "cancelRequestDeposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "sharesToRedeem", + "type": "uint256" + } + ], + "name": "claimSharesAndRequestRedeem", + "outputs": [ + { + "internalType": "uint40", + "name": "requestId", + "type": "uint40" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "controllers", + "type": "address[]" + } + ], + "name": "claimSharesOnBehalf", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "claimableDepositRequest", + "outputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "claimableRedeemRequest", + "outputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_newTotalAssets", + "type": "uint256" + } + ], + "name": "close", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "convertToAssets", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "convertToAssets", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "name": "convertToShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "convertToShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "deposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "deposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "disableWhitelist", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "expireTotalAssets", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "feeRates", + "outputs": [ + { + "components": [ + { + "internalType": "uint16", + "name": "managementRate", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "performanceRate", + "type": "uint16" + } + ], + "internalType": "struct Rates", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getRolesStorage", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "whitelistManager", + "type": "address" + }, + { + "internalType": "address", + "name": "feeReceiver", + "type": "address" + }, + { + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "internalType": "contract FeeRegistry", + "name": "feeRegistry", + "type": "address" + }, + { + "internalType": "address", + "name": "valuationManager", + "type": "address" + } + ], + "internalType": "struct Roles.RolesStorage", + "name": "_rolesStorage", + "type": "tuple" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "address", + "name": "feeRegistry", + "type": "address" + }, + { + "internalType": "address", + "name": "wrappedNativeToken", + "type": "address" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "initiateClosing", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "controller", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isOperator", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "isTotalAssetsValid", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "isWhitelisted", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "maxDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "maxMint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "maxRedeem", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "maxWithdraw", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "mint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "mint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "paused", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "pendingDepositRequest", + "outputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "pendingOwner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "pendingRedeemRequest", + "outputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "previewDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "previewMint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "previewRedeem", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "previewWithdraw", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "redeem", + "outputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "requestDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "referral", + "type": "address" + } + ], + "name": "requestDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "requestRedeem", + "outputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "accounts", + "type": "address[]" + } + ], + "name": "revokeFromWhitelist", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "safe", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "setOperator", + "outputs": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_newTotalAssets", + "type": "uint256" + } + ], + "name": "settleDeposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_newTotalAssets", + "type": "uint256" + } + ], + "name": "settleRedeem", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "share", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "address", + "name": "referral", + "type": "address" + } + ], + "name": "syncDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "totalAssets", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "unpause", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_feeReceiver", + "type": "address" + } + ], + "name": "updateFeeReceiver", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_newTotalAssets", + "type": "uint256" + } + ], + "name": "updateNewTotalAssets", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "uint16", + "name": "managementRate", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "performanceRate", + "type": "uint16" + } + ], + "internalType": "struct Rates", + "name": "newRates", + "type": "tuple" + } + ], + "name": "updateRates", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint128", + "name": "lifespan", + "type": "uint128" + } + ], + "name": "updateTotalAssetsLifespan", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_valuationManager", + "type": "address" + } + ], + "name": "updateValuationManager", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_whitelistManager", + "type": "address" + } + ], + "name": "updateWhitelistManager", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "internalType": "address", + "name": "controller", + "type": "address" + } + ], + "name": "withdraw", + "outputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/rocketwatch/contracts/rocketpool b/rocketwatch/contracts/rocketpool index a08da963..fb7d9c42 160000 --- a/rocketwatch/contracts/rocketpool +++ b/rocketwatch/contracts/rocketpool @@ -1 +1 @@ -Subproject commit a08da9639b8a1619c06f6ec314e36b4765b9452c +Subproject commit fb7d9c428dc3dddc3fbd3e634e3cb365655df89e diff --git a/rocketwatch/main.cfg.sample b/rocketwatch/main.cfg.sample deleted file mode 100644 index e65da708..00000000 --- a/rocketwatch/main.cfg.sample +++ /dev/null @@ -1,96 +0,0 @@ -log_level: `logging:INFO` -discord: { - secret: "" - owner: { - user_id: -1 - server_id: -1 - } - channels: { - default: -1 - dao: -1 - errors: -1 - } -} -execution_layer: { - explorer: "https://etherscan.io" - endpoint: { - current: "http://node1:8545" - mainnet: "http://node1:8545" - archive: "http://node1:8545" - } - etherscan_secret: "" -} -consensus_layer: { - explorer: "https://beaconcha.in" - endpoints: [ - "http://node1:5052", - "http://node2:5052" - ], - beaconcha_secret: "" -} -mongodb: { - uri: "mongodb://mongodb:27017" -} -rocketpool: { - chain: "mainnet" - support: { - role_ids: [] - server_id: -1 - channel_id: -1 - moderator_id: -1 - } - dm_warning: { - channels: [] - } - dao_multisigs: [ - "0x778c08fC151D7AB10042334B6A0929D4fa2983cA", - "0x6efD08303F42EDb68F2D6464BCdCA0824e1C813a", - "0xb867EA3bBC909954d737019FEf5AB25dFDb38CB9" - ] - manual_addresses: { - rocketStorage: "0x1d8f8f00cfa6758d7bE78336684788Fb0ee0Fa46" - rocketSignerRegistry: "0xc1062617d10Ae99E09D941b60746182A87eAB38F" - rocketExitArbitrage: "0x2631618408497d27D455aBA9c99A6f61eF305559" - AirSwap: "0x4572f2554421Bd64Bef1c22c8a81840E8D496BeA" - yearnPool: "0x5c0A86A32c129538D62C106Eb8115a8b02358d57" - curvePool: "0x447Ddd4960d9fdBF6af9a790560d0AF76795CB08" - wstETHToken: "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0" - unstETH: "0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1" - rocketDepositPoolQueue: "0xD95C1B65255Eb69303c0159c656976389F8dA225" - ConstellationDirectory: "0x4343743dBc46F67D3340b45286D8cdC13c8575DE" - LUSD: "0x5f98805A4E8be255a32880FDeC7F6728C6568bA0" - BalancerVault: "0xBA12222222228d8Ba445958a75a0704d566BF2C8" - UniV3_USDC_ETH: "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640" - UniV3_rETH_ETH: "0x553e9C493678d8606d6a5ba284643dB2110Df823" - } -} -modules: { - include: [] - exclude: ["sleep"] - enable_commands: true -} -events: { - lookback_distance: 8 - genesis: 13325233 - block_batch_size: 1000 - status_message: { - default: { - plugin: "DepositPool" - cooldown: 60 - fields: [] - } - dao: { - plugin: "Governance" - cooldown: 300 - fields: [] - } - } -} -other: { - mev_hashes: [] - secrets: { - wakatime: "" - cronitor: "" - anthropic: "" - } -} diff --git a/rocketwatch/plugins/8ball/8ball.py b/rocketwatch/plugins/8ball/8ball.py index 2973d275..a826f9a9 100644 --- a/rocketwatch/plugins/8ball/8ball.py +++ b/rocketwatch/plugins/8ball/8ball.py @@ -2,52 +2,57 @@ import random import random as pyrandom +from discord import Interaction +from discord.app_commands import command from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command from rocketwatch import RocketWatch from utils.embeds import Embed -from utils.visibility import is_hidden_weak +from utils.visibility import is_hidden class EightBall(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - @hybrid_command(name="8ball") - async def eight_ball(self, ctx: Context, question: str): + @command(name="8ball") + async def eight_ball(self, interaction: Interaction, question: str): e = Embed(title="🎱 Magic 8 Ball") if not question.endswith("?"): - e.description = "You must ask a yes or no question to the magic 8 ball (hint: add a `?` at the end of your question)" - await ctx.send(embed=e, ephemeral=True) + e.description = ( + "You must ask a yes or no question to the magic 8 ball" + " (hint: add a `?` at the end of your question)" + ) + await interaction.response.send_message(embed=e, ephemeral=True) return - await ctx.defer(ephemeral=is_hidden_weak(ctx)) - await asyncio.sleep(random.randint(2,5)) - res = pyrandom.choice([ - "As I see it, yes", - "It is certain", - "It is decidedly so", - "Most likely", - "Outlook good", - "Signs point to yes", - "Without a doubt", - "Yes", - "Yes - definitely", - "You may rely on it", - "Don't count on it", - "My reply is no", - "My sources say no", - "Outlook not so good", - "Very doubtful", - "Chances aren't good", - "Unlikely", - "Not likely", - "No", - "Absolutely not" - ]) - e.description = f"> \"{question}\"\n - `{ctx.author.display_name}`\n\nThe Magic 8 Ball says: `{res}`" - await ctx.send(embed=e) + await interaction.response.defer(ephemeral=is_hidden(interaction)) + await asyncio.sleep(random.randint(2, 5)) + res = pyrandom.choice( + [ + "As I see it, yes", + "It is certain", + "It is decidedly so", + "Most likely", + "Outlook good", + "Signs point to yes", + "Without a doubt", + "Yes", + "Yes - definitely", + "You may rely on it", + "Don't count on it", + "My reply is no", + "My sources say no", + "Outlook not so good", + "Very doubtful", + "Chances aren't good", + "Unlikely", + "Not likely", + "No", + "Absolutely not", + ] + ) + e.description = f'> "{question}"\n - `{interaction.user.display_name}`\n\nThe Magic 8 Ball says: `{res}`' + await interaction.followup.send(embed=e) async def setup(bot): diff --git a/rocketwatch/plugins/about/about.py b/rocketwatch/plugins/about/about.py index 6d85e7df..793ea759 100644 --- a/rocketwatch/plugins/about/about.py +++ b/rocketwatch/plugins/about/about.py @@ -1,86 +1,108 @@ +import logging import os import time -from urllib.parse import urlencode +import aiohttp import humanize import psutil -import requests import uptime +from discord import Interaction +from discord.app_commands import command from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command from rocketwatch import RocketWatch from utils import readable -from utils.cfg import cfg -from utils.embeds import Embed -from utils.embeds import el_explorer_url +from utils.config import cfg +from utils.embeds import Embed, el_explorer_url from utils.visibility import is_hidden psutil.getloadavg() BOOT_TIME = time.time() +log = logging.getLogger("rocketwatch.about") + class About(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot self.process = psutil.Process(os.getpid()) - @hybrid_command() - async def about(self, ctx: Context): - """Bot and Server Information""" - await ctx.defer(ephemeral=is_hidden(ctx)) + @command() + async def about(self, interaction: Interaction): + """Bot and server information""" + await interaction.response.defer(ephemeral=is_hidden(interaction)) e = Embed() g = self.bot.guilds code_time = None - if api_key := cfg.get("other.secrets.wakatime"): + if api_key := cfg.other.secrets.wakatime: try: - code_time = requests.get( - "https://wakatime.com/api/v1/users/current/all_time_since_today", - params={ - "project": "rocketwatch", - "api_key": api_key - } - ).json()["data"]["text"] + async with ( + aiohttp.ClientSession() as session, + session.get( + "https://wakatime.com/api/v1/users/current/all_time_since_today", + params={"project": "rocketwatch", "api_key": api_key}, + ) as resp, + ): + code_time = (await resp.json())["data"]["text"] except Exception as err: await self.bot.report_error(err) if code_time: - e.add_field(name="Project Statistics", - value=f"An estimate of {code_time} has been spent developing this bot!", - inline=False) - - e.add_field(name="Bot Statistics", - value=f"{len(g)} Guilds joined and " - f"{humanize.intcomma(sum(guild.member_count for guild in g))} Members reached!", - inline=False) - - address = el_explorer_url(cfg["rocketpool.manual_addresses.rocketStorage"]) + e.add_field( + name="Project Statistics", + value=f"An estimate of {code_time} has been spent developing this bot!", + inline=False, + ) + + e.add_field( + name="Bot Statistics", + value=f"{len(g)} guilds joined and " + f"{humanize.intcomma(sum(guild.member_count or 0 for guild in g))} members reached!", + inline=False, + ) + + address = await el_explorer_url( + cfg.rocketpool.manual_addresses["rocketStorage"] + ) e.add_field(name="Storage Contract", value=address) - e.add_field(name="Chain", value=cfg["rocketpool.chain"].capitalize()) + e.add_field(name="Chain", value=cfg.rocketpool.chain.capitalize()) e.add_field(name="Plugins loaded", value=str(len(self.bot.cogs))) e.add_field(name="Host CPU", value=f"{psutil.cpu_percent():.2f}%") - e.add_field(name="Host Memory", value=f"{psutil.virtual_memory().percent}% used") - e.add_field(name="Bot Memory", value=f"{humanize.naturalsize(self.process.memory_info().rss)} used") + e.add_field( + name="Host Memory", value=f"{psutil.virtual_memory().percent}% used" + ) + e.add_field( + name="Bot Memory", + value=f"{humanize.naturalsize(self.process.memory_info().rss)} used", + ) - load = psutil.getloadavg() - e.add_field(name="Host Load", value='/'.join(str(l) for l in load)) + load = [x / psutil.cpu_count() for x in psutil.getloadavg()] + e.add_field(name="Host Load", value=" / ".join(f"{pct:.0%}" for pct in load)) system_uptime = uptime.uptime() - e.add_field(name="Host Uptime", value=f"{readable.uptime(system_uptime)}") + e.add_field(name="Host Uptime", value=f"{readable.pretty_time(system_uptime)}") bot_uptime = time.time() - BOOT_TIME - e.add_field(name="Bot Uptime", value=f"{readable.uptime(bot_uptime)}") + e.add_field(name="Bot Uptime", value=f"{readable.pretty_time(bot_uptime)}") + + repo_name = "haloooloolo/rocketwatch" # show credits try: + async with ( + aiohttp.ClientSession() as session, + session.get( + f"https://api.github.com/repos/{repo_name}/contributors" + ) as resp, + ): + contributors_data = await resp.json() contributors = [ f"[{c['login']}]({c['html_url']}) ({c['contributions']})" - for c in requests.get("https://api.github.com/repos/InvisibleSymbol/rocketwatch/contributors").json() + for c in contributors_data if "bot" not in c["login"].lower() ] contributors_str = ", ".join(contributors[:10]) @@ -90,32 +112,7 @@ async def about(self, ctx: Context): except Exception as err: await self.bot.report_error(err) - await ctx.send(embed=e) - - @hybrid_command() - async def donate(self, ctx: Context): - """Donate to the Bot Developer""" - await ctx.defer(ephemeral=True) - e = Embed() - e.title = "Donate to the Developer" - e.description = "I hope my bot has been useful to you, it has been a fun experience building it!\n" \ - "Donations will help me keep doing what I love (and pay the server bills haha)\n\n" \ - "I accept Donations on all Ethereum related Chains! (Mainnet, Polygon, Rollups, etc.)" - e.add_field(name="Donation Address", - value="[`0xinvis.eth`](https://etherscan.io/address/0xf0138d2e4037957d7b37de312a16a88a7f83a32a)") - - # add address qrcode - query_string = urlencode({ - "chs" : "128x128", - "cht" : "qr", - "chl" : "0xF0138d2e4037957D7b37De312a16a88A7f83A32a", - "choe": "UTF-8", - "chld": "L|0" - }) - e.set_image(url=f"https://chart.googleapis.com/chart?{query_string}") - - e.set_footer(text="Thank you for your support! <3") - await ctx.send(embed=e) + await interaction.followup.send(embed=e) async def setup(bot): diff --git a/rocketwatch/plugins/activity/activity.py b/rocketwatch/plugins/activity/activity.py index 97cf7281..c8eea94a 100644 --- a/rocketwatch/plugins/activity/activity.py +++ b/rocketwatch/plugins/activity/activity.py @@ -5,41 +5,46 @@ from discord.ext import commands, tasks from rocketwatch import RocketWatch -from utils.cfg import cfg -from utils.rocketpool import rp +from utils.config import cfg -log = logging.getLogger("rich_activity") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.rich_activity") class RichActivity(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.monitor = Monitor("update-activity", api_key=cfg["other.secrets.cronitor"]) - self.loop.start() + self.monitor = Monitor("update-activity", api_key=cfg.other.secrets.cronitor) + self.task.start() - def cog_unload(self): - self.loop.cancel() + async def cog_unload(self): + self.task.cancel() @tasks.loop(seconds=60) - async def loop(self): + async def task(self): self.monitor.ping() log.debug("Updating Discord activity") - - minipool_count = rp.call("rocketMinipoolManager.getActiveMinipoolCount") + + minipool_count = await self.bot.db.minipools.count_documents( + {"beacon.status": "active_ongoing"} + ) + megapool_count = await self.bot.db.megapool_validators.count_documents( + {"beacon.status": "active_ongoing"} + ) + validator_count = minipool_count + megapool_count await self.bot.change_presence( activity=Activity( type=ActivityType.watching, - name=f"{minipool_count:,} minipools" + name=f"{validator_count:,} active validators", ) ) - @loop.before_loop + @task.before_loop async def before_loop(self): await self.bot.wait_until_ready() - - @loop.error - async def on_error(self, err: Exception): + + @task.error + async def on_error(self, err: BaseException): + assert isinstance(err, Exception) await self.bot.report_error(err) diff --git a/rocketwatch/plugins/apr/apr.py b/rocketwatch/plugins/apr/apr.py index 72a94d80..8b865643 100644 --- a/rocketwatch/plugins/apr/apr.py +++ b/rocketwatch/plugins/apr/apr.py @@ -3,24 +3,23 @@ from decimal import Decimal from io import BytesIO +import matplotlib.axes import matplotlib.pyplot as plt -from discord import File +import numpy as np +from discord import File, Interaction +from discord.app_commands import command from discord.ext import commands, tasks -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command from matplotlib.dates import DateFormatter -from motor.motor_asyncio import AsyncIOMotorClient +from matplotlib.ticker import FuncFormatter from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg from utils.embeds import Embed from utils.rocketpool import rp -from utils.shared_w3 import w3, historical_w3 +from utils.shared_w3 import w3, w3_archive from utils.visibility import is_hidden -log = logging.getLogger("apr") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.apr") def to_apr(d1, d2, effective=True): @@ -43,134 +42,134 @@ def get_duration(d1, d2): class APR(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch - self.loop.start() - - def cog_unload(self): - self.loop.cancel() + self.task.start() + + async def cog_unload(self): + self.task.cancel() @tasks.loop(seconds=60) - async def loop(self): + async def task(self): # get latest block update from the db - latest_db_block = await self.db.reth_apr.find_one(sort=[("block", -1)]) + latest_db_block = await self.bot.db.reth_apr.find_one(sort=[("block", -1)]) latest_db_block = 0 if latest_db_block is None else latest_db_block["block"] - cursor_block = historical_w3.eth.getBlock("latest")["number"] + cursor_block = (await w3_archive.eth.get_block("latest")).get("number", 0) while True: # get address of rocketNetworkBalances contract at cursor block - address = rp.uncached_get_address_by_name("rocketNetworkBalances", block=cursor_block) - balance_block = rp.call("rocketNetworkBalances.getBalancesBlock", block=cursor_block, address=address) + address = await rp.uncached_get_address_by_name( + "rocketNetworkBalances", block=cursor_block + ) + balance_block = await rp.call( + "rocketNetworkBalances.getBalancesBlock", + block=cursor_block, + address=address, + ) if balance_block == latest_db_block: break - block_time = w3.eth.getBlock(balance_block)["timestamp"] + block_time = (await w3.eth.get_block(balance_block)).get("timestamp", 0) # abort if the blocktime is older than 120 days if block_time < (datetime.now().timestamp() - 120 * 24 * 60 * 60): break - reth_ratio = solidity.to_float(rp.call("rocketTokenRETH.getExchangeRate", block=cursor_block)) + reth_ratio = solidity.to_float( + await rp.call("rocketTokenRETH.getExchangeRate", block=cursor_block) + ) effectiveness = solidity.to_float( - rp.call("rocketNetworkBalances.getETHUtilizationRate", block=cursor_block, address=address)) - await self.db.reth_apr.insert_one({ - "block" : balance_block, - "time" : block_time, - "value" : reth_ratio, - "effectiveness": effectiveness - }) + await rp.call( + "rocketNetworkBalances.getETHUtilizationRate", + block=cursor_block, + address=address, + ) + ) + await self.bot.db.reth_apr.insert_one( + { + "block": balance_block, + "time": block_time, + "value": reth_ratio, + "effectiveness": effectiveness, + } + ) cursor_block = balance_block - 1 - - @loop.before_loop + + @task.before_loop async def before_loop(self): await self.bot.wait_until_ready() - - @loop.error - async def on_error(self, err: Exception): + + @task.error + async def on_error(self, err: BaseException): + assert isinstance(err, Exception) await self.bot.report_error(err) - @hybrid_command() - async def reth_apr(self, ctx: Context): + @command() + async def reth_apr(self, interaction: Interaction): """Show the current rETH APR""" - await ctx.defer(ephemeral=is_hidden(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) e = Embed() e.title = "Current rETH APR" e.description = "For some comparisons against other LST: [dune dashboard](https://dune.com/rp_community/lst-comparison)" # get the last 30 datapoints - datapoints = await self.db.reth_apr.find().sort("block", -1).limit(180 + 38).to_list(None) + datapoints = ( + await self.bot.db.reth_apr.find() + .sort("block", -1) + .limit(180 + 38) + .to_list(None) + ) if len(datapoints) == 0: e.description = "No data available yet." - return await ctx.send(embed=e) + return await interaction.followup.send(embed=e) # get average meta.NodeFee from db, weighted by meta.NodeOperatorShare - tmp = await self.db.minipools_new.aggregate([ - { - '$match': { - 'beacon.status' : 'active_ongoing', - 'node_fee' : { - '$ne': None + tmp = await ( + await self.bot.db.minipools.aggregate( + [ + { + "$match": { + "beacon.status": "active_ongoing", + "node_fee": {"$ne": None}, + "node_deposit_balance": {"$ne": None}, + } }, - 'node_deposit_balance': { - '$ne': None - } - } - }, { - '$project': { - 'fee' : '$node_fee', - 'share': { - '$multiply': [ - { - '$subtract': [ - 1, { - '$divide': [ - '$node_deposit_balance', 32 + { + "$project": { + "fee": "$node_fee", + "share": { + "$multiply": [ + { + "$subtract": [ + 1, + {"$divide": ["$node_deposit_balance", 32]}, ] - } + }, + 100, ] - }, 100 - ] - } - } - }, { - '$group': { - '_id' : None, - 'pre_numerator': { - '$sum': '$fee' - }, - 'numerator' : { - '$sum': { - '$multiply': [ - '$fee', '$share' - ] + }, } }, - 'denominator' : { - '$sum': '$share' - }, - 'count' : { - '$sum': 1 - } - } - }, { - '$project': { - 'average' : { - '$divide': [ - '$numerator', '$denominator' - ] - }, - 'reference_average': { - '$divide': [ - '$pre_numerator', '$count' - ] + { + "$group": { + "_id": None, + "pre_numerator": {"$sum": "$fee"}, + "numerator": {"$sum": {"$multiply": ["$fee", "$share"]}}, + "denominator": {"$sum": "$share"}, + "count": {"$sum": 1}, + } }, - 'used_pETH_share' : { - '$divide': [ - { - '$divide': [ - '$denominator', '$count' + { + "$project": { + "average": {"$divide": ["$numerator", "$denominator"]}, + "reference_average": { + "$divide": ["$pre_numerator", "$count"] + }, + "used_pETH_share": { + "$divide": [ + {"$divide": ["$denominator", "$count"]}, + 100, ] - }, 100 - ] - } - } - } - ]).to_list(length=1) + }, + } + }, + ] + ) + ).to_list(length=1) node_fee = tmp[0]["average"] if len(tmp) > 0 else 20 peth_share = tmp[0]["used_pETH_share"] if len(tmp) > 0 else 0.75 @@ -196,37 +195,71 @@ async def reth_apr(self, ctx: Context): # calculate the 7 day average if i > 8: y_7d.append(to_apr(datapoints[i - 9], datapoints[i])) - y_7d_virtual.append(to_apr(datapoints[i - 9], datapoints[i], effective=False)) - y_7d_claim = get_duration(datapoints[i - 9], datapoints[i]) / (60 * 60 * 24) + y_7d_virtual.append( + to_apr(datapoints[i - 9], datapoints[i], effective=False) + ) + y_7d_claim = get_duration(datapoints[i - 9], datapoints[i]) / ( + 60 * 60 * 24 + ) else: # if we dont have enough data, we dont show it y_7d.append(None) y_7d_virtual.append(None) - e.add_field(name=f"{y_7d_claim:.1f} Day Average rETH APR", - value=f"{y_7d[-1]:.2%}") - e.add_field(name=f"{y_7d_claim:.1f} Day Average rETH APR (without Effectiveness Drag, Virtual)", - value=f"{y_7d_virtual[-1]:.2%}", inline=False) - fig = plt.figure() - ax1 = plt.gca() - ax2 = plt.twinx() - - ax2.plot(x, y, marker="+", linestyle="", label="Period Average", alpha=0.6, color="orange") - # ax2.plot(x, y_virtual, marker="x", linestyle="", label="Period Average (Virtual)", alpha=0.4) - # ax2.plot(x, y_node_operators, marker="+", linestyle="", label="Node Operator APR", alpha=0.4) - ax2.plot(x, y_7d, linestyle="-", label=f"{y_7d_claim:.1f} Day Average", color="orange") - ax2.plot(x, y_7d_virtual, linestyle="-", label=f"{y_7d_claim:.1f} Day Average (Virtual)", color="green") - ax1.plot(x, y_effectiveness, linestyle="--", label="Effectiveness", alpha=0.7, color="royalblue") - - plt.title("Observed rETH APR values") - plt.xlabel("Date") - plt.grid(True) - plt.xlim(left=x[38]) - plt.xticks(rotation=45) - old_formatter = plt.gca().xaxis.get_major_formatter() - plt.gca().xaxis.set_major_formatter(DateFormatter("%b %d")) - - ax2.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, loc: "{:.1%}".format(x))) - ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, loc: "{:.1%}".format(x))) + e.add_field( + name=f"{y_7d_claim:.1f} Day Average rETH APR", value=f"{y_7d[-1]:.2%}" + ) + e.add_field( + name=f"{y_7d_claim:.1f} Day Average rETH APR (without Effectiveness Drag, Virtual)", + value=f"{y_7d_virtual[-1]:.2%}", + inline=False, + ) + x_arr = np.array(x) + fig, ax1 = plt.subplots() + ax2: matplotlib.axes.Axes = ax1.twinx() + + ax2.plot( + x_arr, + y, + marker="+", + linestyle="", + label="Period Average", + alpha=0.6, + color="orange", + ) + # ax2.plot(x_arr, y_virtual, marker="x", linestyle="", label="Period Average (Virtual)", alpha=0.4) + # ax2.plot(x_arr, y_node_operators, marker="+", linestyle="", label="Node Operator APR", alpha=0.4) + ax2.plot( + x_arr, + y_7d, + linestyle="-", + label=f"{y_7d_claim:.1f} Day Average", + color="orange", + ) + ax2.plot( + x_arr, + y_7d_virtual, + linestyle="-", + label=f"{y_7d_claim:.1f} Day Average (Virtual)", + color="green", + ) + ax1.plot( + x_arr, + y_effectiveness, + linestyle="--", + label="Effectiveness", + alpha=0.7, + color="royalblue", + ) + + ax1.set_title("Observed rETH APR values") + ax1.set_xlabel("Date") + ax1.grid(True) + ax1.set_xlim(left=x_arr[38]) + ax1.tick_params(axis="x", rotation=45) + ax1.xaxis.set_major_formatter(DateFormatter("%b %d")) + + ax2.yaxis.set_major_formatter(FuncFormatter(lambda x, loc: f"{x:.1%}")) + ax1.yaxis.set_major_formatter(FuncFormatter(lambda x, loc: f"{x:.1%}")) ax1.set_ylabel("Effectiveness") ax2.set_ylabel("APR") ax1.set_ylim(top=1) @@ -235,196 +268,216 @@ async def reth_apr(self, ctx: Context): img = BytesIO() fig.tight_layout() - fig.savefig(img, format='png') + fig.savefig(img, format="png") img.seek(0) - fig.clear() - plt.close() - - # reset the x axis formatter - plt.gca().xaxis.set_major_formatter(old_formatter) + plt.close(fig) e.set_image(url="attachment://reth_apr.png") - e.add_field(name="Current Average Effective Commission:", - value=f"{node_fee:.2%} (Observed pETH Share: {peth_share:.2%})", - inline=False) + e.add_field( + name="Current Average Effective Commission", + value=f"{node_fee:.2%} (Observed pETH Share: {peth_share:.2%})", + inline=False, + ) - e.add_field(name="Effectiveness:", - value=f"{y_effectiveness[-1]:.2%}", - inline=False) - await ctx.send(embed=e, file=File(img, "reth_apr.png")) + e.add_field( + name="Effectiveness", value=f"{y_effectiveness[-1]:.2%}", inline=False + ) + await interaction.followup.send(embed=e, file=File(img, "reth_apr.png")) - @hybrid_command() - async def node_apr(self, ctx: Context): + @command() + async def node_apr(self, interaction: Interaction): """Show the current node operator APR""" - await ctx.defer(ephemeral=is_hidden(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) e = Embed() e.title = "Current NO APR" - e.description = "Dashed red lines above and bellow the solid red one are leb8 and leb16 respectively. " \ - "The solid line is the protocol average." + e.description = "" # get the last 30 datapoints - datapoints = await self.db.reth_apr.find().sort("block", -1).limit(180 + 38).to_list(None) + datapoints = ( + await self.bot.db.reth_apr.find() + .sort("block", -1) + .limit(180 + 38) + .to_list(None) + ) if len(datapoints) == 0: e.description = "No data available yet." - return await ctx.send(embed=e) + return await interaction.followup.send(embed=e) # get average meta.NodeFee from db, weighted by meta.NodeOperatorShare - tmp = await self.db.minipools_new.aggregate([ - { - '$match': { - 'beacon.status' : 'active_ongoing', - 'node_fee' : { - '$ne': None + tmp = await ( + await self.bot.db.minipools.aggregate( + [ + { + "$match": { + "beacon.status": "active_ongoing", + "node_fee": {"$ne": None}, + "node_deposit_balance": {"$ne": None}, + } }, - 'node_deposit_balance': { - '$ne': None - } - } - }, { - '$project': { - 'fee' : '$node_fee', - 'share': { - '$multiply': [ - { - '$subtract': [ - 1, { - '$divide': [ - '$node_deposit_balance', 32 + { + "$project": { + "fee": "$node_fee", + "share": { + "$multiply": [ + { + "$subtract": [ + 1, + {"$divide": ["$node_deposit_balance", 32]}, ] - } + }, + 100, ] - }, 100 - ] - } - } - }, { - '$group': { - '_id' : None, - 'pre_numerator': { - '$sum': '$fee' - }, - 'numerator' : { - '$sum': { - '$multiply': [ - '$fee', '$share' - ] + }, } }, - 'denominator' : { - '$sum': '$share' - }, - 'count' : { - '$sum': 1 - } - } - }, { - '$project': { - 'average' : { - '$divide': [ - '$numerator', '$denominator' - ] - }, - 'reference_average': { - '$divide': [ - '$pre_numerator', '$count' - ] + { + "$group": { + "_id": None, + "pre_numerator": {"$sum": "$fee"}, + "numerator": {"$sum": {"$multiply": ["$fee", "$share"]}}, + "denominator": {"$sum": "$share"}, + "count": {"$sum": 1}, + } }, - 'used_pETH_share' : { - '$divide': [ - { - '$divide': [ - '$denominator', '$count' + { + "$project": { + "average": {"$divide": ["$numerator", "$denominator"]}, + "reference_average": { + "$divide": ["$pre_numerator", "$count"] + }, + "used_pETH_share": { + "$divide": [ + {"$divide": ["$denominator", "$count"]}, + 100, ] - }, 100 - ] - } - } - } - ]).to_list(length=1) + }, + } + }, + ] + ) + ).to_list(length=1) node_fee = tmp[0]["average"] if len(tmp) > 0 else 0.2 peth_share = tmp[0]["used_pETH_share"] if len(tmp) > 0 else 0.75 + network_settings = await rp.get_contract_by_name( + "rocketDAOProtocolSettingsNetwork" + ) + leb4_commission = solidity.to_float( + await network_settings.functions.getNodeShare().call() + ) + datapoints = sorted(datapoints, key=lambda x: x["time"]) x = [] - y_7d = [] y_7d_claim = None y_7d_virtual = [] + y_7d_node_operators_leb4 = [] + y_7d_node_operators_leb8_05 = [] y_7d_node_operators_leb8_14 = [] - y_7d_node_operators_leb16_05 = [] - y_7d_node_operators_leb16_14 = [] - y_7d_node_operators_leb16_20 = [] y_7d_solo = [] for i in range(1, len(datapoints)): - # add the data of the datapoint to the x values, need to parse it to a datetime object x.append(datetime.fromtimestamp(datapoints[i]["time"])) - # calculate the 7 day average if i > 8: - y_7d.append(to_apr(datapoints[i - 9], datapoints[i])) - y_7d_virtual.append(to_apr(datapoints[i - 9], datapoints[i], effective=False)) - bare_apr = y_7d_virtual[-1] / Decimal((1 - node_fee)) + y_7d_virtual.append( + to_apr(datapoints[i - 9], datapoints[i], effective=False) + ) + bare_apr = y_7d_virtual[-1] / Decimal(1 - node_fee) y_7d_solo.append(bare_apr) + peth_share_leb4 = 0.875 + y_7d_node_operators_leb4.append( + bare_apr + * Decimal( + 1 + (leb4_commission * peth_share_leb4 / (1 - peth_share_leb4)) + ) + ) peth_share_leb8 = 0.75 - y_7d_node_operators_leb8_14.append(bare_apr * Decimal(1 + (0.14 * peth_share_leb8 / (1 - peth_share_leb8)))) - peth_share_leb16 = 0.5 - y_7d_node_operators_leb16_05.append(bare_apr * Decimal(1 + (0.05 * peth_share_leb16 / (1 - peth_share_leb16)))) - y_7d_node_operators_leb16_14.append(bare_apr * Decimal(1 + (0.14 * peth_share_leb16 / (1 - peth_share_leb16)))) - y_7d_node_operators_leb16_20.append(bare_apr * Decimal(1 + (0.20 * peth_share_leb16 / (1 - peth_share_leb16)))) - y_7d_claim = get_duration(datapoints[i - 9], datapoints[i]) / (60 * 60 * 24) + y_7d_node_operators_leb8_05.append( + bare_apr + * Decimal(1 + (0.05 * peth_share_leb8 / (1 - peth_share_leb8))) + ) + y_7d_node_operators_leb8_14.append( + bare_apr + * Decimal(1 + (0.14 * peth_share_leb8 / (1 - peth_share_leb8))) + ) + y_7d_claim = round( + get_duration(datapoints[i - 9], datapoints[i]) / (60 * 60 * 24) + ) else: - # if we dont have enough data, we dont show it y_7d_solo.append(None) + y_7d_node_operators_leb4.append(None) + y_7d_node_operators_leb8_05.append(None) y_7d_node_operators_leb8_14.append(None) - y_7d_node_operators_leb16_05.append(None) - y_7d_node_operators_leb16_14.append(None) - y_7d_node_operators_leb16_20.append(None) - e.add_field(name=f"{y_7d_claim:.1f} Day Average Node Operator APR:", - value=f"**leb8:** `{y_7d_node_operators_leb8_14[-1]:.2%}`\n" - f"**leb16 5%:** `{y_7d_node_operators_leb16_05[-1]:.2%}` | " - f"**leb16 14%:** `{y_7d_node_operators_leb16_14[-1]:.2%}` | " - f"**leb16 20%:** `{y_7d_node_operators_leb16_20[-1]:.2%}`", inline=False) - - fig = plt.figure() - ax1 = plt.gca() - - # solo apr - ax1.plot(x, y_7d_node_operators_leb8_14, linestyle="-.", label=f"{y_7d_claim:.1f} Day Average (leb8 14%)", color="red", alpha=0.5) - # use area to show region between leb16 20% and leb16 5%. use a spare dotted fill to show the region between - ax1.fill_between(x, y_7d_node_operators_leb16_20, y_7d_node_operators_leb16_05, alpha=0.2, color="red", label=f"{y_7d_claim:.1f} Day Average (leb16 5-20%)") - # plot the leb16 14% line - ax1.plot(x, y_7d_node_operators_leb16_14, linestyle="--", label=f"{y_7d_claim:.1f} Day Average (leb16 14%)", color="red", alpha=0.5) - ax1.plot(x, y_7d_solo, linestyle=":", label=f"{y_7d_claim:.1f} Day Average (solo)", color="black", alpha=0.5) - - plt.title("Observed NO APR values") - plt.grid(True) - plt.xlim(left=x[38]) - plt.xticks(rotation=0) - plt.ylim(bottom=0.02) - old_formatter = plt.gca().xaxis.get_major_formatter() - plt.gca().xaxis.set_major_formatter(DateFormatter("%m.%d")) - - ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, loc: "{:.1%}".format(x))) + e.add_field( + name=f"{y_7d_claim} Day Average Node Operator APR:", + value=f"**leb4 {leb4_commission:.0%}:** `{y_7d_node_operators_leb4[-1]:.2%}`\n" + f"**leb8 5%:** `{y_7d_node_operators_leb8_05[-1]:.2%}` | " + f"**leb8 14%:** `{y_7d_node_operators_leb8_14[-1]:.2%}`", + inline=False, + ) + + x_arr = np.array(x) + fig, ax1 = plt.subplots() + + ax1.plot( + x_arr, + y_7d_node_operators_leb4, + linestyle="-", + label=f"{y_7d_claim} Day Average (leb4 {leb4_commission:.0%})", + color="orange", + ) + ax1.plot( + x_arr, + y_7d_node_operators_leb8_05, + linestyle="--", + label=f"{y_7d_claim} Day Average (leb8 5%)", + color="red", + alpha=0.7, + ) + ax1.plot( + x_arr, + y_7d_node_operators_leb8_14, + linestyle="-.", + label=f"{y_7d_claim:.1f} Day Average (leb8 14%)", + color="red", + alpha=0.5, + ) + ax1.plot( + x_arr, + y_7d_solo, + linestyle=":", + label=f"{y_7d_claim:.1f} Day Average (solo)", + color="black", + alpha=0.5, + ) + + ax1.set_title("Observed NO APR values") + ax1.grid(True) + ax1.set_xlim(left=x_arr[38]) + ax1.tick_params(axis="x", rotation=0) + ax1.set_ylim(bottom=0.02) + ax1.xaxis.set_major_formatter(DateFormatter("%m.%d")) + + ax1.yaxis.set_major_formatter(FuncFormatter(lambda x, loc: f"{x:.1%}")) ax1.legend(loc="lower left") img = BytesIO() fig.tight_layout() - fig.savefig(img, format='png') + fig.savefig(img, format="png") img.seek(0) - fig.clear() - plt.close() + plt.close(fig) - # reset the x axis formatter - plt.gca().xaxis.set_major_formatter(old_formatter) - - e.add_field(name="Current Average Effective Commission:", - value=f"{node_fee:.2%} (Observed pETH Share: {peth_share:.2%})", - inline=False) + e.add_field( + name="Current Average Effective Commission:", + value=f"{node_fee:.2%} (Observed pETH Share: {peth_share:.2%})", + inline=False, + ) e.set_image(url="attachment://no_apr.png") - await ctx.send(embed=e, file=File(img, "no_apr.png")) + await interaction.followup.send(embed=e, file=File(img, "no_apr.png")) + async def setup(bot): await bot.add_cog(APR(bot)) diff --git a/rocketwatch/plugins/beacon_events/beacon_events.py b/rocketwatch/plugins/beacon_events/beacon_events.py index 53879f43..f02ddca4 100644 --- a/rocketwatch/plugins/beacon_events/beacon_events.py +++ b/rocketwatch/plugins/beacon_events/beacon_events.py @@ -1,133 +1,184 @@ import logging -from typing import Optional, cast +from collections.abc import Mapping +from typing import Any, cast -import pymongo -import requests +import aiohttp import eth_utils from eth_typing import BlockNumber from web3.datastructures import MutableAttributeDict as aDict from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg +from utils.block_time import ts_to_block +from utils.config import cfg from utils.embeds import assemble, prepare_args +from utils.event import Event, EventPlugin from utils.readable import cl_explorer_url +from utils.retry import retry from utils.rocketpool import rp from utils.shared_w3 import bacon, w3 -from utils.solidity import date_to_beacon_block, beacon_block_to_date -from utils.event import EventPlugin, Event -from utils.block_time import ts_to_block -from utils.retry import retry +from utils.solidity import beacon_block_to_date, date_to_beacon_block -log = logging.getLogger("beacon_events") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.beacon_events") class BeaconEvents(EventPlugin): def __init__(self, bot: RocketWatch): super().__init__(bot) - self.db = pymongo.MongoClient(cfg["mongodb.uri"]).rocketwatch self.finality_delay_threshold = 3 - def _get_new_events(self) -> list[Event]: - from_block = self.last_served_block + 1 - self.lookback_distance - return self.get_past_events(from_block, self._pending_block) - - def get_past_events(self, from_block: BlockNumber, to_block: BlockNumber) -> list[Event]: - from_slot = max(0, date_to_beacon_block(w3.eth.get_block(from_block - 1).timestamp) + 1) - to_slot = date_to_beacon_block(w3.eth.get_block(to_block).timestamp) - log.info(f"Checking for new beacon chain events in slot range [{from_slot}, {to_slot}]") + async def _get_new_events(self) -> list[Event]: + from_block = BlockNumber(self.last_served_block + 1 - self.lookback_distance) + return await self.get_past_events(from_block, self._pending_block) + + async def get_past_events( + self, from_block: BlockNumber, to_block: BlockNumber + ) -> list[Event]: + from_slot = max( + 0, + date_to_beacon_block( + (await w3.eth.get_block(from_block - 1)).get("timestamp", 0) + ) + + 1, + ) + to_slot = date_to_beacon_block( + (await w3.eth.get_block(to_block)).get("timestamp", 0) + ) + log.info( + f"Checking for new beacon chain events in slot range [{from_slot}, {to_slot}]" + ) events: list[Event] = [] - for slot_number in range(from_slot, to_slot-1): - events.extend(self._get_events_for_slot(slot_number, check_finality=False)) + for slot_number in range(from_slot, to_slot - 1): + events.extend( + await self._get_events_for_slot(slot_number, check_finality=False) + ) # quite expensive and only really makes sense to check toward the head of the chain - events.extend(self._get_events_for_slot(to_slot, check_finality=True)) + events.extend(await self._get_events_for_slot(to_slot, check_finality=True)) log.debug("Finished checking beacon chain events") return events - def _get_events_for_slot(self, slot_number: int, *, check_finality: bool) -> list[Event]: + async def _get_events_for_slot( + self, slot_number: int, *, check_finality: bool + ) -> list[Event]: try: log.debug(f"Checking slot {slot_number}") - beacon_block = bacon.get_block(slot_number)["data"]["message"] - except ValueError as err: - if err.args[0] == "Block does not exist": + beacon_block = (await bacon.get_block(str(slot_number)))["data"]["message"] + except aiohttp.ClientResponseError as e: + if e.status == 404: log.error(f"Beacon block {slot_number} not found, skipping.") return [] - raise err + else: + raise e - events = self._get_slashings(beacon_block) - if proposal_event := self._get_proposal(beacon_block): + events = await self._get_slashings(beacon_block) + if proposal_event := await self._get_proposal(beacon_block): events.append(proposal_event) - if check_finality and (finality_delay_event := self._check_finality(beacon_block)): + if check_finality and ( + finality_delay_event := await self._check_finality(beacon_block) + ): events.append(finality_delay_event) return events - def _get_slashings(self, beacon_block: dict) -> list[Event]: + async def _get_slashings(self, beacon_block: dict) -> list[Event]: slot = int(beacon_block["slot"]) timestamp = beacon_block_to_date(slot) - slashings = [] + slashings: list[dict[str, str | int]] = [] for slash in beacon_block["body"]["attester_slashings"]: att_1 = set(slash["attestation_1"]["attesting_indices"]) att_2 = set(slash["attestation_2"]["attesting_indices"]) - slashings.extend({ - "slashing_type": "Attestation", - "minipool" : index, - "slasher" : beacon_block["proposer_index"], - "timestamp" : timestamp - } for index in att_1.intersection(att_2)) - - slashings.extend({ - "slashing_type": "Proposal", - "minipool" : slash["signed_header_1"]["message"]["proposer_index"], - "slasher" : beacon_block["proposer_index"], - "timestamp" : timestamp - } for slash in beacon_block["body"]["proposer_slashings"]) - - events = [] + slashings.extend( + { + "slashing_type": "Attestation", + "validator": index, + "slasher": beacon_block["proposer_index"], + } + for index in att_1.intersection(att_2) + ) + + slashings.extend( + { + "slashing_type": "Proposal", + "validator": slash["signed_header_1"]["message"]["proposer_index"], + "slasher": beacon_block["proposer_index"], + } + for slash in beacon_block["body"]["proposer_slashings"] + ) + + events: list[Event] = [] for slash in slashings: - minipool = self.db.minipools.find_one({"validator": int(slash["minipool"])}) - if not minipool: - log.info(f"Skipping slashing of unknown validator {slash['minipool']}") + validator = int(slash["validator"]) + slasher = slash["slasher"] + minipool: Mapping[str, Any] | None = await self.bot.db.minipools.find_one( + {"validator_index": validator} + ) + megapool: ( + Mapping[str, Any] | None + ) = await self.bot.db.megapool_validators.find_one( + {"validator_index": validator} + ) + rp_pool = minipool or megapool + if rp_pool is None: + log.info(f"Skipping slashing of unknown validator {validator}") continue unique_id = ( - f"slash-{slash['minipool']}" - f":slasher-{slash['slasher']}" + f"slash-{validator}" + f":slasher-{slasher}" f":slashing-type-{slash['slashing_type']}" f":{timestamp}" ) - slash["minipool"] = cl_explorer_url(slash["minipool"]) - slash["slasher"] = cl_explorer_url(slash["slasher"]) - slash["node_operator"] = minipool["node_operator"] - slash["event_name"] = "minipool_slash_event" - - args = prepare_args(aDict(slash)) - if embed := assemble(args): - events.append(Event( - topic="beacon_events", - embed=embed, - event_name=slash["event_name"], - unique_id=unique_id, - block_number=ts_to_block(timestamp), - )) + args = aDict( + { + "event_name": "validator_slash_event", + "slashing_type": slash["slashing_type"], + "validator": await cl_explorer_url(validator), + "slasher": await cl_explorer_url(slasher), + "node_operator": rp_pool["node_operator"], + "timestamp": timestamp, + } + ) + args = await prepare_args(args) + if embed := await assemble(args): + events.append( + Event( + topic="beacon_events", + embed=embed, + event_name="validator_slash_event", + unique_id=unique_id, + block_number=await ts_to_block(timestamp), + ) + ) return events @retry(tries=5, delay=10, backoff=2, max_delay=30) - def _get_proposal(self, beacon_block: dict) -> Optional[Event]: + async def _get_proposal(self, beacon_block: dict) -> Event | None: if not (payload := beacon_block["body"].get("execution_payload")): # no proposed block return None + if not (api_key := cfg.consensus_layer.beaconcha_secret): + log.warning("Missing beaconcha.in API key") + return None + validator_index = int(beacon_block["proposer_index"]) - if not (minipool := self.db.minipools.find_one({"validator": validator_index})): - # not proposed by a minipool + minipool: Mapping[str, Any] | None = await self.bot.db.minipools.find_one( + {"validator_index": validator_index} + ) + megapool: ( + Mapping[str, Any] | None + ) = await self.bot.db.megapool_validators.find_one( + {"validator_index": validator_index} + ) + rp_pool = minipool or megapool + if not rp_pool: + # not proposed by RP validator return None log.info(f"Validator {validator_index} proposed a block") @@ -135,22 +186,16 @@ def _get_proposal(self, beacon_block: dict) -> Optional[Event]: timestamp = int(payload["timestamp"]) block_number = cast(BlockNumber, int(payload["block_number"])) - if not (api_key := cfg["consensus_layer.beaconcha_secret"]): - log.warning("Missing beaconcha.in API key") - return None - # fetch from beaconcha.in because beacon node is unaware of MEV bribes endpoint = f"https://beaconcha.in/api/v1/execution/block/{block_number}" - response = requests.get(endpoint, headers={"apikey": api_key}) + async with ( + aiohttp.ClientSession() as session, + session.get(endpoint, headers={"apikey": api_key}) as resp, + ): + response_body = await resp.json() - if response.status_code != 200: - log.warning(f"Error code {response.status_code} from {endpoint}") - return None - - response_body = response.json() log.debug(f"{response_body = }") - - proposal_data = response.json()["data"][0] + proposal_data = response_body["data"][0] log.debug(f"{proposal_data = }") block_reward_eth = solidity.to_float(proposal_data["producerReward"]) @@ -166,23 +211,25 @@ def _get_proposal(self, beacon_block: dict) -> Optional[Event]: fee_recipient = proposal_data["feeRecipient"] args = { - "node_operator": minipool["node_operator"], - "minipool": minipool["address"], + "node_operator": rp_pool["node_operator"], + "validator": await cl_explorer_url(validator_index), "slot": int(beacon_block["slot"]), "reward_amount": block_reward_eth, - "timestamp": timestamp + "timestamp": timestamp, } - if eth_utils.is_same_address(fee_recipient, rp.get_address_by_name("rocketSmoothingPool")): + if eth_utils.address.is_same_address( + fee_recipient, await rp.get_address_by_name("rocketSmoothingPool") + ): args["event_name"] = "mev_proposal_smoothie_event" - args["smoothie_amount"] = w3.eth.get_balance( + args["smoothie_amount"] = await w3.eth.get_balance( w3.to_checksum_address(fee_recipient), block_identifier=block_number ) else: args["event_name"] = "mev_proposal_event" - args = prepare_args(aDict(args)) - if not (embed := assemble(args)): + args = await prepare_args(aDict(args)) + if not (embed := await assemble(args)): return None return Event( @@ -190,45 +237,51 @@ def _get_proposal(self, beacon_block: dict) -> Optional[Event]: embed=embed, event_name=args["event_name"], unique_id=f"mev_proposal:{block_number}:{timestamp}", - block_number=block_number + block_number=block_number, ) - def _check_finality(self, beacon_block: dict) -> Optional[Event]: + async def _check_finality(self, beacon_block: dict) -> Event | None: slot_number = int(beacon_block["slot"]) epoch_number = slot_number // 32 timestamp = beacon_block_to_date(slot_number) try: # calculate finality delay - finality_checkpoint = bacon.get_finality_checkpoint(state_id=str(slot_number)) - last_finalized_epoch = int(finality_checkpoint["data"]["finalized"]["epoch"]) + finality_checkpoint = await bacon.get_finality_checkpoint(str(slot_number)) + last_finalized_epoch = int( + finality_checkpoint["data"]["finalized"]["epoch"] + ) finality_delay = epoch_number - last_finalized_epoch - except requests.exceptions.HTTPError: + except aiohttp.ClientResponseError: log.exception("Failed to get finality checkpoints") return None # latest finality delay from db - delay_entry = self.db.finality_checkpoints.find_one({"epoch": epoch_number - 1}) + delay_entry = await self.bot.db.finality_checkpoints.find_one( + {"epoch": epoch_number - 1} + ) prev_finality_delay = delay_entry["finality_delay"] if delay_entry else 0 - self.db.finality_checkpoints.update_one( + await self.bot.db.finality_checkpoints.update_one( {"epoch": epoch_number}, {"$set": {"finality_delay": finality_delay}}, - upsert=True + upsert=True, ) # if finality delay recovers, notify if finality_delay < self.finality_delay_threshold <= prev_finality_delay: - log.info(f"Finality delay recovered from {prev_finality_delay} to {finality_delay}") + log.info( + f"Finality delay recovered from {prev_finality_delay} to {finality_delay}" + ) event_name = "finality_delay_recover_event" args = { "event_name": event_name, "finality_delay": finality_delay, "timestamp": timestamp, - "epoch": epoch_number + "epoch": epoch_number, } - args = prepare_args(aDict(args)) - if not (embed := assemble(args)): + args = await prepare_args(aDict(args)) + if not (embed := await assemble(args)): return None event = Event( @@ -236,21 +289,23 @@ def _check_finality(self, beacon_block: dict) -> Optional[Event]: embed=embed, event_name=event_name, unique_id=f"finality_delay_recover:{epoch_number}", - block_number=ts_to_block(timestamp) + block_number=await ts_to_block(timestamp), ) return event - if finality_delay >= max(prev_finality_delay + 1, self.finality_delay_threshold): + if finality_delay >= max( + prev_finality_delay + 1, self.finality_delay_threshold + ): log.warning(f"Finality increased to {finality_delay} epochs") event_name = "finality_delay_event" args = { - "event_name" : event_name, + "event_name": event_name, "finality_delay": finality_delay, - "timestamp" : timestamp, - "epoch" : epoch_number + "timestamp": timestamp, + "epoch": epoch_number, } - args = prepare_args(aDict(args)) - if not (embed := assemble(args)): + args = await prepare_args(aDict(args)) + if not (embed := await assemble(args)): return None return Event( @@ -258,7 +313,7 @@ def _check_finality(self, beacon_block: dict) -> Optional[Event]: embed=embed, event_name=event_name, unique_id=f"{epoch_number}:finality_delay", - block_number=ts_to_block(timestamp) + block_number=await ts_to_block(timestamp), ) return None diff --git a/rocketwatch/plugins/beacon_states/beacon_states.py b/rocketwatch/plugins/beacon_states/beacon_states.py deleted file mode 100644 index 7e1e72c3..00000000 --- a/rocketwatch/plugins/beacon_states/beacon_states.py +++ /dev/null @@ -1,93 +0,0 @@ -import logging - -from discord.ext import commands -from discord.ext.commands import hybrid_command, Context -from motor.motor_asyncio import AsyncIOMotorClient - -from rocketwatch import RocketWatch -from utils.cfg import cfg -from utils.embeds import Embed, el_explorer_url -from utils.readable import render_tree_legacy -from utils.shared_w3 import w3 -from utils.visibility import is_hidden - -log = logging.getLogger("beacon_states") -log.setLevel(cfg["log_level"]) - - -class BeaconStates(commands.Cog): - def __init__(self, bot: RocketWatch): - self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).get_database("rocketwatch") - - @hybrid_command() - async def beacon_states(self, ctx: Context): - await ctx.defer(ephemeral=is_hidden(ctx)) - # fetch from db - res = await self.db.minipools_new.find({ - "beacon.status": {"$exists": True} - }).to_list(None) - data = { - "pending": {}, - "active" : {}, - "exiting": {}, - "exited" : {} - } - exiting_valis = [] - for minipool in res: - match minipool["beacon"]["status"]: - case "pending_initialized": - data["pending"]["initialized"] = data["pending"].get("initialized", 0) + 1 - case "pending_queued": - data["pending"]["queued"] = data["pending"].get("queued", 0) + 1 - case "active_ongoing": - data["active"]["ongoing"] = data["active"].get("ongoing", 0) + 1 - case "active_exiting": - data["exiting"]["voluntarily"] = data["exiting"].get("voluntarily", 0) + 1 - exiting_valis.append(minipool) - case "active_slashed": - data["exiting"]["slashed"] = data["exiting"].get("slashed", 0) + 1 - exiting_valis.append(minipool) - case "exited_unslashed" | "exited_slashed" | "withdrawal_possible" | "withdrawal_done": - if minipool["beacon"]["slashed"]: - data["exited"]["slashed"] = data["exited"].get("slashed", 0) + 1 - else: - data["exited"]["voluntarily"] = data["exited"].get("voluntarily", 0) + 1 - case _: - logging.warning(f"Unknown status {minipool['status']}") - - embed = Embed(title="Beacon Chain Minipool States", color=0x00ff00) - description = "```\n" - # render dict as a tree like structure - description += render_tree_legacy(data, "Minipool States") - if 0 < len(exiting_valis) <= 24: - description += "\n\n--- Exiting Minipools ---\n\n" - # array of validator attribute, sorted by index - valis = sorted([v["validator_index"] for v in exiting_valis], key=lambda x: x) - description += ", ".join([str(v) for v in valis]) - description += "```" - elif len(exiting_valis) > 24: - description += "```\n**Exiting Node Operators:**\n" - node_operators = {} - # dedupe, add count of validators with matching node operator - for v in exiting_valis: - node_operators[v["node_operator"]] = node_operators.get(v["node_operator"], 0) + 1 - # turn into list - node_operators = list(node_operators.items()) - # sort by count - node_operators.sort(key=lambda x: x[1], reverse=True) - description += "" - # use el_explorer_url - description += ", ".join([f"{el_explorer_url(w3.toChecksumAddress(v))} ({c})" for v, c in node_operators[:16]]) - # append ",…" if more than 16 - if len(node_operators) > 16: - description += ",…" - else: - description += "```" - - embed.description = description - await ctx.send(embed=embed) - - -async def setup(self): - await self.add_cog(BeaconStates(self)) diff --git a/rocketwatch/plugins/call/call.py b/rocketwatch/plugins/call/call.py new file mode 100644 index 00000000..7349fb3c --- /dev/null +++ b/rocketwatch/plugins/call/call.py @@ -0,0 +1,199 @@ +import contextlib +import json +import logging + +import humanize +from discord import Interaction +from discord.app_commands import Choice, command, describe +from discord.ext.commands import Cog +from discord.ui import Modal, TextInput + +from rocketwatch import RocketWatch +from utils import solidity +from utils.file import TextFile +from utils.rocketpool import rp +from utils.shared_w3 import w3 +from utils.visibility import is_hidden_role_controlled + +log = logging.getLogger("rocketwatch.call") + + +class CallModal(Modal): + def __init__(self, cog, function, block, address, raw_output, abi_inputs): + func_name = function.rsplit(".", 1)[1] if "." in function else function + super().__init__(title=func_name[:45]) + self.cog = cog + self.function = function + self.block = block + self.address = address + self.raw_output = raw_output + self.abi_inputs = abi_inputs + self.param_inputs: list[TextInput] = [] + for inp in abi_inputs: + text_input: TextInput = TextInput( + label=f"{inp['name']} ({inp['type']})"[:45], required=True + ) + self.add_item(text_input) + self.param_inputs.append(text_input) + + async def on_submit(self, interaction): + await interaction.response.defer( + ephemeral=is_hidden_role_controlled(interaction) + ) + args = [] + errors = [] + for text_input, inp in zip(self.param_inputs, self.abi_inputs, strict=True): + val = text_input.value + with contextlib.suppress(json.JSONDecodeError, ValueError): + val = json.loads(val) + error = self._validate(val, inp["type"]) + if error: + errors.append(f"`{inp['name']}`: {error}") + else: + args.append(val) + if errors: + await interaction.followup.send( + content="Validation failed:\n" + "\n".join(errors) + ) + return + await self.cog._execute_call( + interaction, self.function, args, self.block, self.address, self.raw_output + ) + + @staticmethod + def _validate(value, abi_type): + if abi_type == "bool": + if not isinstance(value, bool): + return f"expected bool, got `{value!r}`" + elif abi_type == "address": + if not isinstance(value, str) or not w3.is_address(value): + return f"expected address, got `{value!r}`" + elif abi_type == "string": + if not isinstance(value, str): + return f"expected string, got `{value!r}`" + elif abi_type.startswith("uint") or abi_type.startswith("int"): + if not isinstance(value, int) or isinstance(value, bool): + return f"expected integer, got `{value!r}`" + elif abi_type.startswith("bytes"): + if isinstance(value, str): + if not value.startswith("0x"): + return f"expected hex bytes, got `{value!r}`" + elif not isinstance(value, (bytes, list)): + return f"expected bytes, got `{value!r}`" + return None + + +class Call(Cog): + def __init__(self, bot: RocketWatch): + self.bot = bot + self.function_names: list[str] = [] + + @Cog.listener() + async def on_ready(self): + if self.function_names: + return + + for contract in rp.addresses.copy(): + try: + c = await rp.get_contract_by_name(contract) + for entry in c.abi: + if ( + entry.get("type") == "function" + and "name" in entry + and entry.get("stateMutability") in ("view", "pure") + ): + func_id = f"{entry['name']}({','.join(inp['type'] for inp in entry.get('inputs', []))})" + self.function_names.append(f"{contract}.{func_id}") + except Exception: + log.exception(f"Could not get function list for {contract}") + + @command() + @describe(block="call against block state") + async def call( + self, + interaction: Interaction, + function: str, + block: str = "latest", + address: str | None = None, + raw_output: bool = False, + ): + """Manually call a function on a protocol contract""" + block_id: int | str = int(block) if block.isnumeric() else block + + # Look up ABI inputs for the function + abi_inputs = [] + try: + contract_name, func_id = function.rsplit(".", 1) + contract = await rp.get_contract_by_name(contract_name) + for entry in contract.abi: + if entry.get("type") == "function" and "name" in entry: + entry_id = f"{entry['name']}({','.join(inp['type'] for inp in entry.get('inputs', []))})" + if entry_id == func_id: + abi_inputs = entry.get("inputs", []) + break + except Exception: + pass + + if abi_inputs: + modal = CallModal(self, function, block_id, address, raw_output, abi_inputs) + await interaction.response.send_modal(modal) + else: + await interaction.response.defer( + ephemeral=is_hidden_role_controlled(interaction) + ) + await self._execute_call( + interaction, function, [], block_id, address, raw_output + ) + + async def _execute_call( + self, interaction, function, args, block, address, raw_output + ): + try: + v = await rp.call( + function, + *args, + block=block, + address=w3.to_checksum_address(address) if address else None, + ) + except Exception as err: + await interaction.followup.send(content=f"Exception: ```{err!r}```") + return + try: + g = await rp.estimate_gas_for_call(function, *args, block=block) + except Exception as err: + g = "N/A" + if ( + isinstance(err, ValueError) + and err.args + and "code" in err.args + and err.args[0]["code"] == -32000 + ): + g += f" ({err.args[0]['message']})" + + if isinstance(v, int) and abs(v) >= 10**12 and not raw_output: + v = solidity.to_float(v) + g = humanize.intcomma(g) + func_name = function.split("(")[0] + text = f"`block: {block}`\n`gas estimate: {g}`\n`{func_name}({', '.join([repr(a) for a in args])}): " + if len(text + str(v)) > 2000: + text += "too long, attached as file`" + await interaction.followup.send( + text, file=TextFile(str(v), "exception.txt") + ) + else: + text += f"{v!s}`" + await interaction.followup.send(content=text) + + @call.autocomplete("function") + async def match_function_name( + self, interaction: Interaction, current: str + ) -> list[Choice[str]]: + return [ + Choice(name=name, value=name) + for name in self.function_names + if current.lower() in name.lower() + ][:25] + + +async def setup(bot): + await bot.add_cog(Call(bot)) diff --git a/rocketwatch/plugins/chat_summary/chat_summary.py b/rocketwatch/plugins/chat_summary/chat_summary.py deleted file mode 100644 index 80f4db24..00000000 --- a/rocketwatch/plugins/chat_summary/chat_summary.py +++ /dev/null @@ -1,160 +0,0 @@ -import logging -import re -from datetime import datetime, timedelta, timezone -from io import BytesIO - -import anthropic -import pytz -import tiktoken -from discord import File, DeletedReferencedMessage -from discord.channel import TextChannel -from discord.ext import commands -from discord.ext.commands import Context, is_owner -from discord.ext.commands import hybrid_command -from motor.motor_asyncio import AsyncIOMotorClient - -from rocketwatch import RocketWatch -from utils.cfg import cfg -from utils.embeds import Embed - -log = logging.getLogger("chat_summary") -log.setLevel(cfg["log_level"]) - - -class ChatSummary(commands.Cog): - def __init__(self, bot: RocketWatch): - self.bot = bot - self.client = anthropic.AsyncAnthropic(api_key=cfg["other.secrets.anthropic"]) - # log all possible engines - self.tokenizer = tiktoken.encoding_for_model("gpt-4-turbo") - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch - - @classmethod - def message_to_text(cls, message, index): - text = f"{message.author.global_name or message.author.name} on {message.created_at.strftime('%a at %H:%M')}:\n {message.content}" - - # if there is an image attached, add it to the text as a note - metadata = [] - if message.attachments: - metadata.append(f"{len(message.attachments)} attachments") - if message.embeds: - metadata.append(f"{len(message.embeds)} embeds") - # replies and make sure the reference is not deleted - if message.reference and not isinstance(message.reference.resolved, - DeletedReferencedMessage) and message.reference.resolved: - # show name of referenced message author - # and the first 10 characters of the referenced message - metadata.append( - f"reply to \"{message.reference.resolved.content[:32]}…\" from {message.reference.resolved.author.name}") - if metadata: - text += f" <{', '.join(metadata)}>\n" - # replace all <@[0-9]+> with the name of the user - for mention in message.mentions: - text = text.replace(f"<@{mention.id}>", f"@{mention.name}") - # remove all emote ids, i.e change <:emote_name:emote_id> to <:emote_name> using regex - text = re.sub(r":[0-9]+>", ":>", text) - return text - - @hybrid_command() - @is_owner() - async def summarize_chat(self, ctx: Context): - await ctx.defer(ephemeral=True) - last_ts = await self.db["last_summary"].find_one({"channel_id": ctx.channel.id}) - # ratelimit - if last_ts and (datetime.now(timezone.utc) - last_ts["timestamp"].replace(tzinfo=pytz.utc)) < timedelta(hours=6): - await ctx.send("You can only summarize once every 6 hours.", ephemeral=True) - return - if ctx.channel.id not in [405163713063288832]: - await ctx.send("You can't summarize here.", ephemeral=True) - return - msg = await ctx.channel.send("Summarizing chat…") - last_ts = last_ts["timestamp"].replace(tzinfo=pytz.utc) if last_ts and "timestamp" in last_ts else datetime.now(timezone.utc) - timedelta(days=365) - prompt = ( - "Task Description:\n" - "I need a summary of the entire chat log. This summary should be presented in the form of a bullet list.\n\n" - "Format and Length Requirements:\n" - "- The bullet list must be kept short and concise, but the list has to cover the entire chat log. Make at most around 5 bullet points.\n" - "- Each bullet point should represent a distinct topic discussed in the chat log.\n\n" - "Content Constraints:\n" - "- Limit each topic to a single bullet point in the list.\n" - "- Omit any topics that are uninteresting or not crucial to the overall understanding of the chat log.\n" - "- If any content in the chat log goes against guidelines, refer to it in a safe and compliant manner, without detailing the specific content.\n\n" - "Response Instruction:\n" - "- Respond only with the bullet list summary as specified. Do not include any additional commentary or response outside of this list.\n\n" - "Truncated Example Output:\n" - "----------------\n" - "- Discussions between invis, langers, knoshua and more about the meaning of life.\n" - "- The current status of the war in europe was discussed.\n" - "- Patches announced that he has been taking a vacation in switzerland and shared some images of his skiing.}\n" - "----------------\n\n" - "Please begin the task now." - ) - response, prompt, msgs = await self.prompt_model(ctx.channel, prompt, last_ts) - if not response: - await msg.delete() - await ctx.send(content="Not enough messages to summarize.") - return - es = [Embed()] - es[0].title = f"Chat Summarization of {msgs} messages since {last_ts.strftime('%Y-%m-%d %H:%M')}" - res = response.content[-1].text - # split content in multiple embeds if it is too long. limit for description is 4096 - while len(res): - if len(res) > 4096: - # find last newline before 4096 characters - idx = res[:4096].rfind("\n") - # if there is no newline, just split at 4096 - if idx == -1: - idx = 4096 - # add embed - es[-1].description = res[:idx] - es[-1].set_footer(text="") - # create new embed - es.append(Embed()) - res = res[idx:] - else: - es[-1].description = res - res = "" - token_usage = response.usage.input_tokens + (response.usage.output_tokens * 5) # completion tokens are 3x more expensive - es[-1].set_footer( - text=f"Request cost: ${token_usage / 1000000 * 3:.2f} | Tokens: {response.usage.input_tokens + response.usage.output_tokens} | /donate if you like this command") - # attach the prompt as a file - f = BytesIO(prompt.encode("utf-8")) - f.name = "prompt._log" - f = File(f, filename=f"prompt_log_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}._log") - # send message in the channel - await ctx.send("done", ephemeral=True) - await msg.edit(embeds=es, attachments=[f]) - # save the timestamp of the last summary - await self.db["last_summary"].update_one({"channel_id": ctx.channel.id}, {"$set": {"timestamp": datetime.now(timezone.utc)}}, upsert=True) - - # a function that generates the prompt for the model by taking an array of messages, a prefix and a suffix - def generate_prompt(self, messages, prefix, suffix): - messages.sort(key=lambda x: x.created_at) - prompt = "\n".join([self.message_to_text(message, i) for i, message in enumerate(messages)]).replace("\n\n", "\n") - return f"{prefix}\n\n{prompt}\n\n{suffix}" - - async def prompt_model(self, channel: TextChannel, prompt: str, cut_off_ts: int) -> tuple[anthropic.types.Message, str, int]: - messages = [message async for message in channel.history(limit=4096) if message.content != ""] - messages = [message for message in messages if message.author.id != self.bot.user.id] - messages = [message for message in messages if message.created_at > cut_off_ts] - if len(messages) < 320: - return None, None, None - prefix = "The following is a chat log. Everything prefixed with `>` is a quote." - log.info(f"Prompt len: {len(self.tokenizer.encode(self.generate_prompt(messages, prefix, prompt)))}") - while len(self.tokenizer.encode(self.generate_prompt(messages, prefix, prompt))) > 100000 - 4096: - # remove the oldest message - messages.pop(0) - prompt = self.generate_prompt(messages, prefix, prompt) - # get all models - response = await self.client.messages.create( - model="claude-3-sonnet-20240229", # Update this to the desired model - max_tokens=4096, - messages=[{"role": "user", "content": prompt}] - ) - # find all {message:index} in response["choices"][0]["message"]["content"] - log.debug(response.content[-1].text) - return response, prompt, len(messages) - - -async def setup(bot): - await bot.add_cog(ChatSummary(bot)) diff --git a/rocketwatch/plugins/chicken_soup/chicken_soup.py b/rocketwatch/plugins/chicken_soup/chicken_soup.py new file mode 100644 index 00000000..8b70f18e --- /dev/null +++ b/rocketwatch/plugins/chicken_soup/chicken_soup.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta + +from discord import Interaction +from discord.app_commands import command +from discord.ext import commands + +from rocketwatch import RocketWatch + + +class ChickenSoup(commands.Cog): + def __init__(self, bot: RocketWatch): + self.bot = bot + self.duration = timedelta(minutes=5) + self.dispense_end: dict[int, datetime] = {} + + @command() + async def chicken_soup(self, interaction: Interaction): + if interaction.channel_id is not None: + self.dispense_end[interaction.channel_id] = datetime.now() + self.duration + await interaction.response.send_message( + "https://tenor.com/view/muppets-muppet-show-swedish-chef-chicken-pot-gif-9362214582988742217" + ) + + @commands.Cog.listener() + async def on_message(self, message) -> None: + if message.author == self.bot.user: + return + + if message.channel.id not in self.dispense_end: + return + + if datetime.now() > self.dispense_end[message.channel.id]: + del self.dispense_end[message.channel.id] + return + + await message.add_reaction("🐔") + await message.add_reaction("🍲") + + +async def setup(bot): + await bot.add_cog(ChickenSoup(bot)) diff --git a/rocketwatch/plugins/collateral/collateral.py b/rocketwatch/plugins/collateral/collateral.py index b99c5d48..cdc24ea1 100644 --- a/rocketwatch/plugins/collateral/collateral.py +++ b/rocketwatch/plugins/collateral/collateral.py @@ -1,100 +1,143 @@ +import functools import logging +import operator from io import BytesIO +from typing import Any -import inflect import matplotlib as mpl +import matplotlib.colors as mcolors import matplotlib.pyplot as plt import numpy as np -from discord import File -from discord.app_commands import describe +from discord import File, Interaction +from discord.app_commands import command, describe from discord.ext import commands -from discord.ext.commands import Context, hybrid_command -from discord.utils import as_chunks -from matplotlib.ticker import FuncFormatter from eth_typing import ChecksumAddress +from matplotlib.ticker import FuncFormatter +from pymongo.asynchronous.database import AsyncDatabase from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg -from utils.embeds import Embed, resolve_ens +from utils.embeds import Embed, ens, resolve_ens from utils.rocketpool import rp +from utils.shared_w3 import w3 from utils.visibility import is_hidden -log = logging.getLogger("collateral") -log.setLevel(cfg["log_level"]) - -p = inflect.engine() +log = logging.getLogger("rocketwatch.collateral") def get_percentiles(percentiles, counts): for p in percentiles: - yield p, np.percentile(counts, p, interpolation='nearest') + yield p, np.percentile(counts, p, method="nearest") -async def collateral_distribution_raw(ctx: Context, distribution): +async def collateral_distribution_raw(interaction: Interaction, distribution): e = Embed() e.title = "Collateral Distribution" description = "```\n" for collateral, nodes in distribution: - description += f"{collateral:>5}%: " \ - f"{nodes:>4} {p.plural('node', nodes)}\n" + description += ( + f"{collateral:>5}%: {nodes:>4} {'node' if nodes == 1 else 'nodes'}\n" + ) description += "```" e.description = description - await ctx.send(embed=e) - - -def get_node_minipools_and_collateral() -> dict[ChecksumAddress, dict[str, int]]: - node_staking = rp.get_contract_by_name("rocketNodeStaking") - minipool_manager = rp.get_contract_by_name("rocketMinipoolManager") - eb16s, eb8s, rpl_stakes = [], [], [] - - nodes = rp.call("rocketNodeManager.getNodeAddresses", 0, 10_000) - for node_batch in as_chunks(nodes, 500): - eb16s += [r.results[0] for r in rp.multicall.aggregate( - minipool_manager.functions.getNodeStakingMinipoolCountBySize(node, 16 * 10**18) for node in node_batch - ).results] - eb8s += [r.results[0] for r in rp.multicall.aggregate( - minipool_manager.functions.getNodeStakingMinipoolCountBySize(node, 8 * 10**18) for node in node_batch - ).results] - rpl_stakes += [r.results[0] for r in rp.multicall.aggregate( - node_staking.functions.getNodeRPLStake(node) for node in node_batch - ).results] - + await interaction.followup.send(embed=e) + + +async def get_node_collateral_data( + db: AsyncDatabase, +) -> dict[ChecksumAddress, dict[str, int | float]]: + pipeline: list[dict[str, Any]] = [ + { + "$match": { + "$or": [ + {"staking_minipool_count": {"$gt": 0}}, + {"megapool.active_validator_count": {"$gt": 0}}, + ] + } + }, + { + "$project": { + "address": 1, + "rpl_stake": {"$ifNull": ["$rpl.total_stake", 0]}, + "bonded": { + "$add": [ + { + "$multiply": [ + {"$ifNull": ["$effective_node_share", 0]}, + {"$ifNull": ["$staking_minipool_count", 0]}, + 32, + ] + }, + {"$ifNull": ["$megapool.node_bond", 0]}, + ] + }, + "borrowed": { + "$add": [ + { + "$multiply": [ + { + "$subtract": [ + 1, + {"$ifNull": ["$effective_node_share", 0]}, + ] + }, + {"$ifNull": ["$staking_minipool_count", 0]}, + 32, + ] + }, + {"$ifNull": ["$megapool.user_capital", 0]}, + ] + }, + "validators": { + "$add": [ + {"$ifNull": ["$staking_minipool_count", 0]}, + {"$ifNull": ["$megapool.active_validator_count", 0]}, + ] + }, + } + }, + ] + results = await (await db.node_operators.aggregate(pipeline)).to_list() return { - nodes[i]: { - "eb8s" : eb8s[i], - "eb16s" : eb16s[i], - "rplStaked": rpl_stakes[i] - } for i in range(len(nodes)) + doc["address"]: { + "bonded": doc["bonded"], + "borrowed": doc["borrowed"], + "rpl_stake": doc["rpl_stake"], + "validators": doc["validators"], + } + for doc in results } -def get_average_collateral_percentage_per_node(collateral_cap, bonded): - # get stakes for each node - stakes = list(get_node_minipools_and_collateral().values()) - # get the current rpl price - rpl_price = solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice")) +async def get_average_collateral_percentage_per_node( + db: AsyncDatabase, collateral_cap: int | None, bonded: bool +): + stakes = list((await get_node_collateral_data(db)).values()) + rpl_price = solidity.to_float(await rp.call("rocketNetworkPrices.getRPLPrice")) - result = {} - # process the data + node_collaterals = [] for node in stakes: - # get the minipool eth value - minipool_value = int(node["eb16s"]) * 16 + int(node["eb8s"]) * (8 if bonded else 24) - if not minipool_value: + eth_value = node["bonded"] if bonded else node["borrowed"] + if not eth_value: continue - # rpl stake value - rpl_stake_value = solidity.to_float(node["rplStaked"]) * rpl_price - # cap rpl stake at x% of minipool_value using collateral_cap + rpl_stake = node["rpl_stake"] + collateral = rpl_stake * rpl_price / eth_value * 100 if collateral_cap: - rpl_stake_value = min(rpl_stake_value, minipool_value * collateral_cap / 100) - # calculate percentage - percentage = rpl_stake_value / minipool_value * 100 - # round percentage to 5% steps - percentage = (percentage // 5) * 5 - # add to result + collateral = min(collateral, collateral_cap) + node_collaterals.append((rpl_stake, collateral)) + + effective_bound = max(perc for rpl, perc in node_collaterals) + possible_step_sizes = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100] + step_size = possible_step_sizes[ + np.argmin([abs(effective_bound / 30 - s) for s in possible_step_sizes]) + ] + + result: dict[float, list[float]] = {} + for rpl_stake, percentage in node_collaterals: + percentage = step_size * (percentage * 10 // (step_size * 10)) if percentage not in result: result[percentage] = [] - result[percentage].append(rpl_stake_value / rpl_price) + result[percentage].append(rpl_stake) return result @@ -103,52 +146,48 @@ class Collateral(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - @hybrid_command() - @describe(node_address="Node Address or ENS to highlight", - bonded="Calculate collateral as a percent of bonded eth instead of borrowed") - async def node_tvl_vs_collateral(self, - ctx: Context, - node_address: str = None, - bonded: bool = False): + @command() + @describe( + node_address="Address or ENS of node to highlight", + bonded="Calculate collateral as a percent of bonded eth instead of borrowed", + ) + async def node_tvl_vs_collateral( + self, + interaction: Interaction, + node_address: str | None = None, + bonded: bool = False, + ) -> None: """ Show a scatter plot of collateral ratios for given node TVLs """ - await ctx.defer(ephemeral=is_hidden(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) display_name = None address = None if node_address is not None: - display_name, address = await resolve_ens(ctx, node_address) + display_name, address = await resolve_ens(interaction, node_address) if display_name is None: return - rpl_price = solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice")) - data = get_node_minipools_and_collateral() - - # Calculate each node's tvl and collateral and add it to the data - def node_tvl(node): - return int(node["eb8s"]) * 8 + int(node["eb16s"]) * 16 + rpl_price = solidity.to_float(await rp.call("rocketNetworkPrices.getRPLPrice")) + data = await get_node_collateral_data(self.bot.db) def node_collateral(node): - eth = int(node["eb16s"]) * 16 + int(node["eb8s"]) * (8 if bonded else 24) + eth = node["bonded"] if bonded else node["borrowed"] if not eth: return 0 - return 100 * (solidity.to_float(node["rplStaked"]) * rpl_price) / eth - - def node_minipools(node): - return int(node["eb16s"]) + int(node["eb8s"]) + return 100 * node["rpl_stake"] * rpl_price / eth x, y, c = [], [], [] - max_minipools = 0 + max_validators = 0 for node in data.values(): - minis = node_minipools(node) - if minis <= 0: + if not node["bonded"]: continue - x.append(node_tvl(node)) + x.append(node["bonded"]) y.append(node_collateral(node)) - c.append(minis) - max_minipools = max(max_minipools, minis) + c.append(int(node["validators"])) + max_validators = max(max_validators, int(node["validators"])) e = Embed() img = BytesIO() @@ -159,7 +198,7 @@ def node_minipools(node): paths = ax.scatter(x, y, c=c, alpha=0.25, norm="log") polys = ax2.hexbin(x, y, gridsize=20, bins="log", xscale="log", cmap="viridis") # fill the background in with the default color. - ax2.set_facecolor(mpl.colors.to_rgba(mpl.colormaps["viridis"](0), 0.9)) + ax2.set_facecolor(mcolors.to_rgba(mpl.colormaps["viridis"](0), 0.9)) max_nodes = max(polys.get_array()) # log-scale the X-axis to account for thomas @@ -167,14 +206,14 @@ def node_minipools(node): # Add a legend for the color-coding on the scatter plot formatToInt = "{x:.0f}" - cb = plt.colorbar(mappable=paths, ax=ax, format=formatToInt) - cb.set_label('Minipools') - cb.set_ticks([1,10,100,max_minipools]) + cb = fig.colorbar(mappable=paths, ax=ax, format=formatToInt) + cb.set_label("Validator Count") + cb.set_ticks([1, 10, 100, max_validators]) # Add a legend for the color-coding on the hex distribution - cb = plt.colorbar(mappable=polys, ax=ax2, format=formatToInt) - cb.set_label('Nodes') - cb.set_ticks([1,10,100,max_nodes - 1]) + cb = fig.colorbar(mappable=polys, ax=ax2, format=formatToInt) + cb.set_label("Nodes") + cb.set_ticks([1, 10, 100, max_nodes - 1]) # Add labels and units ylabel = f"Collateral (percent {'bonded' if bonded else 'borrowed'})" @@ -191,11 +230,13 @@ def node_minipools(node): # Print a vline and hline through the requested node try: target_node = data[address] - ax.plot(node_tvl(target_node), node_collateral(target_node), 'ro') - ax2.plot(node_tvl(target_node), node_collateral(target_node), 'ro') + ax.plot(target_node["bonded"], node_collateral(target_node), "ro") + ax2.plot(target_node["bonded"], node_collateral(target_node), "ro") e.description = f"Showing location of {display_name}" except KeyError: - await ctx.send(f"{display_name} not found in data set - it must have at least one minipool") + await interaction.followup.send( + f"{display_name} not found in data set - it must have at least one validator" + ) return # Add horizontal lines showing the 10-15% range made optimal by RPIP-30 @@ -205,7 +246,7 @@ def node_minipools(node): fig.tight_layout() img = BytesIO() - fig.savefig(img, format='png') + fig.savefig(img, format="png") img.seek(0) fig.clear() plt.close() @@ -213,36 +254,43 @@ def node_minipools(node): e.title = "Node TVL vs Collateral Scatter Plot" e.set_image(url="attachment://graph.png") f = File(img, filename="graph.png") - await ctx.send(embed=e, files=[f]) + await interaction.followup.send(embed=e, files=[f]) img.close() - @hybrid_command() - @describe(raw="Show Raw Distribution Data", - cap_collateral="Cap Collateral to 150%", - bonded="Calculate collateral as percent of bonded eth instead of borrowed") - async def collateral_distribution(self, - ctx: Context, - raw: bool = False, - cap_collateral: bool = True, - collateral_cap: int = 150, - bonded: bool = False): + @command() + @describe( + raw="Show raw distribution data", + collateral_cap="Bound the plot at a specific collateral percentage", + bonded="Calculate collateral as percent of bonded eth instead of borrowed", + ) + async def collateral_distribution( + self, + interaction: Interaction, + raw: bool = False, + collateral_cap: int = 15, + bonded: bool = False, + ) -> None: """ Show the distribution of collateral across nodes. """ - await ctx.defer(ephemeral=is_hidden(ctx)) - - data = get_average_collateral_percentage_per_node(collateral_cap or 150 if cap_collateral else None, bonded) + await interaction.response.defer(ephemeral=is_hidden(interaction)) - counts = [] - for collateral, nodes in data.items(): - counts.extend([collateral] * len(nodes)) - counts = list(sorted(counts)) - bins = np.bincount(counts) - distribution = [(i, bins[i]) for i in range(len(bins)) if i % 5 == 0] + data = await get_average_collateral_percentage_per_node( + self.bot.db, collateral_cap, bonded + ) + distribution = [ + (collateral, len(nodes)) + for collateral, nodes in sorted(data.items(), key=lambda x: x[0]) + ] + counts: list[float] = functools.reduce( + operator.iadd, + ([collateral] * num_nodes for collateral, num_nodes in distribution), + [], + ) # If the raw data were requested, print them and exit early if raw: - await collateral_distribution_raw(ctx, distribution[::-1]) + await collateral_distribution_raw(interaction, distribution[::-1]) return e = Embed() @@ -251,49 +299,222 @@ async def collateral_distribution(self, fig, ax = plt.subplots() ax2 = ax.twinx() - bars = dict(distribution) - x_keys = [str(x) for x in bars] - rects = ax.bar(x_keys, bars.values(), color=str(e.color), align='edge') + x_keys = [str(x) for x, _ in distribution] + rects = ax.bar( + x_keys, [y for _, y in distribution], color=str(e.color), align="edge" + ) ax.bar_label(rects) - ax.set_xticklabels(x_keys, rotation='vertical') - ax.set_xlabel(f"Collateral Percent of { 'Bonded' if bonded else 'Borrowed'} Eth") + ax.set_xticklabels(x_keys, rotation="vertical") + ax.set_xlabel(f"Collateral Percent of {'Bonded' if bonded else 'Borrowed'} Eth") - for label in ax.xaxis.get_major_ticks()[1::2]: - label.label.set_visible(False) ax.set_ylim(top=(ax.get_ylim()[1] * 1.1)) ax.yaxis.set_visible(False) - ax.get_xaxis().set_major_formatter(FuncFormatter( - lambda n, _: f"{x_keys[n] if n < len(x_keys) else 0}{'+' if n == len(x_keys)-1 and cap_collateral else ''}%") + ax.get_xaxis().set_major_formatter( + FuncFormatter( + lambda n, _: ( + f"{x_keys[n] if n < len(x_keys) else 0}{'+' if n == len(x_keys) - 1 else ''}%" + ) + ) ) - staked_distribution = [ - (collateral, sum(nodes)) for collateral, nodes in sorted(data.items(), key=lambda x: x[0]) - ] - - bars = dict(staked_distribution) - line = ax2.plot(x_keys, [bars.get(int(x), 0) for x in x_keys]) + bars = { + collateral: sum(nodes) + for collateral, nodes in sorted(data.items(), key=lambda x: x[0]) + } + line = ax2.plot(x_keys, [bars.get(float(x), 0) for x in x_keys]) ax2.set_ylim(top=(ax2.get_ylim()[1] * 1.1)) - ax2.tick_params(axis='y', colors=line[0].get_color()) - ax2.get_yaxis().set_major_formatter(FuncFormatter(lambda y, _: f"{int(y / 10 ** 3)}k")) + ax2.tick_params(axis="y", colors=line[0].get_color()) + ax2.get_yaxis().set_major_formatter( + FuncFormatter(lambda y, _: f"{int(y / 10**3)}k") + ) fig.tight_layout() ax.legend(rects, ["Node Operators"], loc="upper left") - ax2.legend(line, ["Effective Staked RPL"], loc="upper right") - fig.savefig(img, format='png') + ax2.legend(line, ["Staked RPL"], loc="upper right") + fig.savefig(img, format="png") img.seek(0) fig.clear() plt.close() - e.title = "Average Collateral Distribution" - e.set_image(url="attachment://graph.png") - f = File(img, filename="graph.png") - percentile_strings = [f"{x[0]}th percentile: {int(x[1])}% collateral" for x in - get_percentiles([50, 75, 90, 99], counts)] - e.description = f"Total Effective Staked RPL: {sum(bars.values()):,}" + e.title = "RPL Collateral Distribution" + e.set_image(url="attachment://collateral_distribution.png") + f = File(img, filename="collateral_distribution.png") + percentile_strings = [ + f"{x[0]}th percentile: {int(x[1])}% collateral" + for x in get_percentiles([50, 75, 90, 99], counts) + ] + e.description = f"Total Staked RPL: {sum(bars.values()):,.0f}" + e.set_footer(text="\n".join(percentile_strings)) + await interaction.followup.send(embed=e, files=[f]) + img.close() + + @command() + @describe(node_address="Node Address or ENS to highlight") + async def voter_share_distribution( + self, + interaction: Interaction, + node_address: str | None = None, + ) -> None: + """ + Show the distribution of RPL staked per borrowed ETH for megapool validators. + """ + await interaction.response.defer(ephemeral=is_hidden(interaction)) + + address, display_name = None, None + if node_address is not None: + if "." in node_address: + display_name = node_address + address = await ens.resolve_name(node_address) + elif w3.is_address(address): + address = w3.to_checksum_address(node_address) + display_name = address + + log.info(f"{address =}, {display_name = }") + + pipeline: list[dict[str, Any]] = [ + { + "$match": { + "megapool.active_validator_count": {"$gt": 0}, + "megapool.user_capital": {"$gt": 0}, + "rpl.megapool_stake": {"$gt": 0}, + } + }, + { + "$project": { + "address": 1, + "rpl_stake": "$rpl.megapool_stake", + "borrowed": "$megapool.user_capital", + "validators": "$megapool.active_validator_count", + } + }, + ] + results = await (await self.bot.db.node_operators.aggregate(pipeline)).to_list() + + e = Embed() + e.title = "Megapool RPL per Borrowed ETH" + + if not results: + e.description = "No data available." + return await interaction.followup.send(embed=e) + + total_rpl = sum(doc["rpl_stake"] for doc in results) + total_borrowed = sum(doc["borrowed"] for doc in results) + avg_ratio = total_rpl / total_borrowed + + ratios = [doc["rpl_stake"] / doc["borrowed"] for doc in results] + + # Cap at the 95th percentile to avoid long tail dominating the histogram + cap = float(np.percentile(ratios, 95)) + capped_max = max(min(r, cap) for r in ratios) + possible_step_sizes = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100] + step_size = possible_step_sizes[ + np.argmin([abs(capped_max / 30 - s) for s in possible_step_sizes]) + ] + + buckets: dict[float, list[float]] = {} + validator_counts: dict[float, int] = {} + for doc in results: + ratio = min(doc["rpl_stake"] / doc["borrowed"], cap) + bucket = step_size * (ratio * 10 // (step_size * 10)) + if bucket not in buckets: + buckets[bucket] = [] + validator_counts[bucket] = 0 + buckets[bucket].append(doc["rpl_stake"]) + validator_counts[bucket] += doc["validators"] + + distribution = [ + (bucket, validator_counts[bucket]) for bucket in sorted(buckets.keys()) + ] + + counts: list[float] = functools.reduce( + operator.iadd, + ([bucket] * num_nodes for bucket, num_nodes in distribution), + [], + ) + + img = BytesIO() + fig, ax = plt.subplots() + + # Mark the overall average RPL per borrowed ETH + avg_pos = avg_ratio / step_size + ax.axvline( + avg_pos, + color="tab:olive", + linestyle="--", + zorder=1, + label=f"Average Stake ({avg_ratio:.1f})", + ) + + leb8_14_breakeven_ratio = avg_ratio / 9 + breakeven_pos = leb8_14_breakeven_ratio / step_size + ax.axvline( + breakeven_pos, + color="tab:red", + linestyle="--", + zorder=1, + label=f"LEB8 14% Breakeven ({leb8_14_breakeven_ratio:.1f})", + ) + + # Highlight target node if provided + if address is not None: + target = await self.bot.db.node_operators.find_one( + {"address": address}, + {"rpl.megapool_stake": 1, "megapool.user_capital": 1}, + ) + if target is not None: + rpl_stake = (target.get("rpl") or {}).get("megapool_stake", 0) + borrowed = (target.get("megapool") or {}).get("user_capital", 0) + target_ratio = (rpl_stake / borrowed) if (borrowed > 0) else 0 + target_pos = min(target_ratio, cap) / step_size + ax.axvline( + target_pos, + color="black", + linestyle="-", + zorder=3, + label=f"{display_name} ({target_ratio:.1f})", + ) + + # Match decimal places to step size precision + decimals = ( + len(f"{step_size:.10f}".rstrip("0").split(".")[1]) if step_size % 1 else 0 + ) + x_keys = [f"{x:.{decimals}f}" for x, _ in distribution] + rects = ax.bar( + x_keys, [y for _, y in distribution], color=str(e.color), align="edge" + ) + ax.bar_label(rects) + + ax.set_xticklabels(x_keys, rotation="vertical") + ax.set_xlabel("RPL per borrowed ETH") + + ax.set_ylim(top=(ax.get_ylim()[1] * 1.1)) + ax.set_ylabel("Validators") + ax.get_xaxis().set_major_formatter( + FuncFormatter( + lambda n, _: ( + f"{x_keys[n] if n < len(x_keys) else 0}{'+' if n == len(x_keys) - 1 else ''}" + ) + ) + ) + + fig.tight_layout() + ax.legend(loc="upper right") + fig.savefig(img, format="png") + img.seek(0) + + fig.clear() + plt.close() + + e.set_image(url="attachment://voter_share_distribution.png") + f = File(img, filename="voter_share_distribution.png") + percentile_strings = [ + f"{x[0]}th percentile: {x[1]:.{decimals}f} RPL/ETH" + for x in get_percentiles([50, 75, 90, 99], counts) + ] e.set_footer(text="\n".join(percentile_strings)) - await ctx.send(embed=e, files=[f]) + await interaction.followup.send(embed=e, files=[f]) img.close() diff --git a/rocketwatch/plugins/commissions/commissions.py b/rocketwatch/plugins/commissions/commissions.py index 1d12b828..a1214da9 100644 --- a/rocketwatch/plugins/commissions/commissions.py +++ b/rocketwatch/plugins/commissions/commissions.py @@ -3,45 +3,41 @@ import numpy as np import seaborn as sns -from discord import File +from discord import File, Interaction +from discord.app_commands import command from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command from matplotlib import pyplot as plt -from motor.motor_asyncio import AsyncIOMotorClient from rocketwatch import RocketWatch -from utils.cfg import cfg from utils.embeds import Embed from utils.visibility import is_hidden -log = logging.getLogger("commissions") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.commissions") class Commissions(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - # connect to local mongodb - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).get_database("rocketwatch") - @hybrid_command() - async def commission_history(self, ctx: Context): + @command() + async def commission_history(self, interaction: Interaction): """ Show the history of commissions. """ - await ctx.defer(ephemeral=is_hidden(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) - e = Embed(title='Commission History') + e = Embed(title="Commission History") - minipools = await self.db.minipools.find().sort("validator", 1).to_list(None) + minipools = ( + await self.bot.db.minipools.find().sort("validator_index", 1).to_list(None) + ) # create dot chart of minipools # x-axis: validator # y-axis: node_fee ygrid = list(reversed(range(5, 21))) step_size = int(len(minipools) / len(ygrid) / 2) - data = [[0] * len(ygrid)] + data: list[list[int]] = [[0] * len(ygrid)] for pool in minipools: if sum(data[-1]) > step_size: # normalize data @@ -53,29 +49,29 @@ async def commission_history(self, ctx: Context): # normalize data # data[-1] = [x / max(data[-1]) for x in data[-1]] # heatmap distribution over time - data = np.array(data).T - ax = sns.heatmap(data, cmap="viridis", yticklabels=ygrid, xticklabels=False) + data_array = np.array(data).T + fig, ax = plt.subplots() + sns.heatmap( + data_array, cmap="viridis", yticklabels=ygrid, xticklabels=False, ax=ax + ) ax.set_yticklabels(ax.get_yticklabels(), rotation=0, fontsize=8) # set y ticks ax.set_ylabel("Node Fee") - plt.tight_layout() - - # save figure to buffer - buf = BytesIO() - plt.savefig(buf, format="png") - buf.seek(0) + fig.tight_layout() # respond with image img = BytesIO() - plt.savefig(img, format="png") + fig.savefig(img, format="png") img.seek(0) - plt.close() + plt.close(fig) e.set_image(url="attachment://chart.png") e.add_field(name="Total Minipools", value=len(minipools)) e.add_field(name="Bar Width", value=f"{step_size} minipools") # send data - await ctx.send(content="", embed=e, files=[File(img, filename="chart.png")]) + await interaction.followup.send( + content="", embed=e, files=[File(img, filename="chart.png")] + ) img.close() diff --git a/rocketwatch/plugins/constellation/constellation.py b/rocketwatch/plugins/constellation/constellation.py deleted file mode 100644 index 761b2e1d..00000000 --- a/rocketwatch/plugins/constellation/constellation.py +++ /dev/null @@ -1,178 +0,0 @@ -import logging -import math - -from discord import Interaction -from discord.app_commands import command -from discord.ext.commands import Cog -from motor.motor_asyncio import AsyncIOMotorClient - -from rocketwatch import RocketWatch -from utils import solidity -from utils.cfg import cfg -from utils.shared_w3 import w3 -from utils.rocketpool import rp -from utils.visibility import is_hidden_weak -from utils.embeds import Embed, el_explorer_url -from utils.event_logs import get_logs - - -cog_id = "constellation" -log = logging.getLogger(cog_id) -log.setLevel(cfg["log_level"]) - - -class Constellation(Cog): - def __init__(self, bot: RocketWatch): - self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch - - async def _fetch_num_operators(self) -> int: - whitelist_contract = rp.get_contract_by_name("Constellation.Whitelist") - - if db_entry := (await self.db.last_checked_block.find_one({"_id": cog_id})): - last_checked_block = db_entry["block"] - num_operators = db_entry["operators"] - else: - last_checked_block = 20946650 # contract deployment - num_operators = 0 - - b_from = last_checked_block + 1 - b_to = w3.eth.get_block_number() - - num_operators += len(get_logs(whitelist_contract.events.OperatorAdded, b_from, b_to)) - num_operators -= len(get_logs(whitelist_contract.events.OperatorRemoved, b_from, b_to)) - for event_log in get_logs(whitelist_contract.events.OperatorsAdded, b_from, b_to): - num_operators += len(event_log.args.operators) - for event_log in get_logs(whitelist_contract.events.OperatorsRemoved, b_from, b_to): - num_operators -= len(event_log.args.operators) - - await self.db.last_checked_block.replace_one( - {"_id": cog_id}, - {"_id": cog_id, "block": b_to, "operators": num_operators}, - upsert=True - ) - - return num_operators - - @command() - async def constellation(self, interaction: Interaction): - """ - Summary of Gravita Constellation protocol stats. - """ - await interaction.response.defer(ephemeral=is_hidden_weak(interaction)) - - supernode_contract = rp.get_contract_by_name("Constellation.SuperNodeAccount") - distributor_contract = rp.get_contract_by_name("Constellation.OperatorDistributor") - info_calls: dict[str, int] = { - res.function_name: res.results[0] for res in rp.multicall.aggregate([ - supernode_contract.functions.getNumMinipools(), - supernode_contract.functions.getEthStaked(), - supernode_contract.functions.getEthMatched(), - supernode_contract.functions.getRplStaked(), - supernode_contract.functions.bond(), - supernode_contract.functions.maxValidators(), - distributor_contract.functions.getTvlEth(), - distributor_contract.functions.getTvlRpl(), - distributor_contract.functions.minimumStakeRatio() - ]).results - } - - num_minipools: int = info_calls["getNumMinipools"] - eth_staked: int = solidity.to_int(info_calls["getEthStaked"]) - eth_matched: int = solidity.to_int(info_calls["getEthMatched"]) - rpl_staked: float = solidity.to_float(info_calls["getRplStaked"]) - eth_bond: int = solidity.to_int(info_calls["bond"]) - max_validators: int = info_calls["maxValidators"] - - # update operator count - num_operators: int = await self._fetch_num_operators() - - vault_address_eth: str = rp.get_address_by_name("Constellation.ETHVault") - vault_balance_eth = rp.call("WETH.balanceOf", vault_address_eth) - tvl_eth: float = solidity.to_float(info_calls["getTvlEth"] + vault_balance_eth) - - vault_address_rpl: str = rp.get_address_by_name("Constellation.RPLVault") - vault_balance_rpl = rp.call("rocketTokenRPL.balanceOf", vault_address_rpl) - tvl_rpl: float = solidity.to_float(info_calls["getTvlRpl"] + vault_balance_rpl) - - min_rpl_stake_ratio: float = solidity.to_float(info_calls["minimumStakeRatio"]) - rpl_ratio: float = solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice")) - rpl_stake_perc: float = rpl_staked * rpl_ratio / eth_matched - - balance_eth: float = solidity.to_float(w3.eth.getBalance(distributor_contract.address)) - balance_rpl: float = solidity.to_float(rp.call("rocketTokenRPL.balanceOf", distributor_contract.address)) - - # number of new minipools that can be created with available liquidity - if min_rpl_stake_ratio > 0: - max_eth_matched: float = (rpl_staked + balance_rpl) * rpl_ratio / min_rpl_stake_ratio - max_minipools_rpl: float = (max_eth_matched - eth_matched) // (32 - eth_bond) - else: - max_minipools_rpl: float = math.inf - - max_minipools_eth: float = balance_eth // eth_bond - max_new_minipools = min(max_minipools_eth, max_minipools_rpl) - - # break-even time for new minipools - solo_apr: float = 0.033 - deployment_gas: int = 2_250_000 - gas_price_wei: int = w3.eth.gas_price - operator_commission: float = (0.1 + 0.04 * min(1.0, 10 * rpl_stake_perc)) / 2 - daily_income_wei: int = round((32 - eth_bond) * 1e18 * solo_apr * operator_commission / 365) - break_even_days: int = round(deployment_gas * gas_price_wei / daily_income_wei) - - embed = Embed(title="Gravita Constellation") - embed.add_field( - name="Node Address", - value=el_explorer_url(supernode_contract.address, name=" Supernode"), - inline=False - ) - embed.add_field(name="Minipools", value=num_minipools) - embed.add_field(name="Operators", value=num_operators) - embed.add_field(name="MP Limit", value=f"{max_validators} ({max_validators * num_operators:,})") - embed.add_field(name="ETH Stake", value=f"{eth_staked:,}") - embed.add_field(name="RPL Stake", value=f"{rpl_staked:,.2f}") - embed.add_field(name="RPL Bond", value=f"{rpl_stake_perc:.2%}") - - if max_minipools_eth > 0: - balance_status_eth = f"`{max_minipools_eth:,.0f}` pools" - else: - shortfall_eth: float = eth_bond - (balance_eth % eth_bond) - balance_status_eth = f"`-{shortfall_eth:,.2f}`" - - if max_minipools_rpl > 0: - count_fmt: str = "∞" if math.isinf(max_minipools_rpl) else f"{max_minipools_rpl:,.0f}" - balance_status_rpl = f"`{count_fmt}` pools" - else: - new_eth_matched = eth_matched + 32 - eth_bond - new_rpl_required = new_eth_matched * min_rpl_stake_ratio / rpl_ratio - shortfall_rpl: float = new_rpl_required - rpl_staked - balance_rpl - balance_status_rpl = f"`-{shortfall_rpl:,.2f}`" - - if max_new_minipools > 0: - balance_status = f"`{max_new_minipools:,.0f}` new minipool(s) can be created!" - else: - balance_status = "No new minipools can be created." - - embed.add_field( - name="Distributor Balances", - value=( - f"`{balance_eth:,.2f}` ETH ({balance_status_eth})\n" - f"`{balance_rpl:,.2f}` RPL ({balance_status_rpl})\n" - f"{balance_status}" - ), - inline=False - ) - embed.add_field(name="Gas Price", value=f"{(gas_price_wei / 1e9):,.2f} gwei") - embed.add_field(name="Break-Even", value=f"{break_even_days:,} days") - embed.add_field( - name="Protocol TVL", - value=f"{el_explorer_url(vault_address_eth, name=' xrETH')}: `{tvl_eth:,.2f}` ETH\n" - f"{el_explorer_url(vault_address_rpl, name=' xRPL')}: `{tvl_rpl:,.2f}` RPL", - inline=False - ) - - await interaction.followup.send(embed=embed) - - -async def setup(bot): - await bot.add_cog(Constellation(bot)) diff --git a/rocketwatch/plugins/cow_orders/cow_orders.py b/rocketwatch/plugins/cow_orders/cow_orders.py index ec1b980c..b1f2cf6b 100644 --- a/rocketwatch/plugins/cow_orders/cow_orders.py +++ b/rocketwatch/plugins/cow_orders/cow_orders.py @@ -1,233 +1,170 @@ +import contextlib import logging -from datetime import datetime, timedelta +from typing import Any, TypedDict, cast -import pymongo -import requests -from datetime import timezone - -from discord.ext.commands import Context, hybrid_command +from discord import Interaction +from discord.app_commands import command +from eth_typing import BlockNumber, ChecksumAddress +from web3.contract import AsyncContract from web3.datastructures import MutableAttributeDict as aDict +from web3.types import EventData from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg -from utils.embeds import assemble, prepare_args, Embed +from utils.embeds import Embed, assemble, prepare_args +from utils.event import Event, EventPlugin from utils.rocketpool import rp from utils.shared_w3 import w3 -from utils.event import EventPlugin, Event -from utils.visibility import is_hidden_weak - -log = logging.getLogger("cow_orders") -log.setLevel(cfg["log_level"]) - - -class CowOrders(EventPlugin): - def __init__(self, bot: RocketWatch): - super().__init__(bot, timedelta(seconds=60)) - self.state = "OK" - self.db = pymongo.MongoClient(cfg["mongodb.uri"]).rocketwatch - # create the cow_orders collection if it doesn't exist - # limit the collection to 10000 entries - # create an index on order_uid - if "cow_orders" not in self.db.list_collection_names(): - self.db.create_collection("cow_orders", capped=True, size=10_000) - self.collection = self.db.cow_orders - self.collection.create_index("order_uid", unique=True) - - self.tokens = [ - str(rp.get_address_by_name("rocketTokenRPL")).lower(), - str(rp.get_address_by_name("rocketTokenRETH")).lower() - ] - - @hybrid_command() - async def cow(self, ctx: Context, tnx: str): - # https://etherscan.io/tx/0x47d96c6310f08b473f2c9948d6fbeef1084f0b393c2263d2fc8d5dc624f97fe3 - if "etherscan.io/tx/" not in tnx: - await ctx.send("nop", ephemeral=True) - await ctx.defer(ephemeral=is_hidden_weak(ctx)) - e = Embed() - url = tnx.replace("etherscan.io", "explorer.cow.fi") - e.description = f"[cow explorer]({url})" - await ctx.send(embed=e) - - def _get_new_events(self) -> list[Event]: - if self.state == "RUNNING": - log.error("Cow Orders plugin was interrupted while running. Re-initializing...") - self.__init__(self.bot) - self.state = "RUNNING" - try: - result = self.check_for_new_events() - self.state = "OK" - except Exception as e: - log.error(f"Error while checking for new Cow Orders: {e}") - result = [] - self.state = "ERROR" - return result - - # noinspection PyTypeChecker - def check_for_new_events(self): - log.info("Checking Cow Orders") - payload = [] - - # get all pending orders from the cow api (https://api.cow.fi/mainnet/api/v1/auction) - - response = requests.get("https://cow-proxy.invis.workers.dev/mainnet/api/v1/auction") - if response.status_code != 200: - log.error("Cow API returned non-200 status code: %s", response.text) - raise Exception("Cow API returned non-200 status code") - - cow_orders = response.json()["orders"] - - """ - entity example: - { - "creationDate": "2023-01-25T04:48:02.751347Z", - "owner": "0x40586600a136652f6d0a6cc6a62b6bd1bef7ae9a", - "uid": "0x2f3750251ab20018addd59c7a9e57845782cdf21b9c53516dcdb9e3627ebb7e840586600a136652f6d0a6cc6a62b6bd1bef7ae9a63d9eef8", - "availableBalance": "108475037", - "executedBuyAmount": "0", - "executedSellAmount": "0", - "executedSellAmountBeforeFees": "0", - "executedFeeAmount": "0", - "invalidated": false, - "status": "open", - "class": "limit", - "surplusFee": "10050959", - "surplusFeeTimestamp": "2023-01-26T14:51:51.453450Z", - "executedSurplusFee": null, - "settlementContract": "0x9008d19f58aabd9ed0d60971565aa8510560ab41", - "fullFeeAmount": "13254445", - "isLiquidityOrder": false, - "sellToken": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - "buyToken": "0x347a96a5bd06d2e15199b032f46fb724d6c73047", - "receiver": "0x40586600a136652f6d0a6cc6a62b6bd1bef7ae9a", - "sellAmount": "20000000", - "buyAmount": "17091759130902", - "validTo": 1675226872, - "appData": "0xc1164815465bff632c198b8455e9a421c07e8ce426c8cd1b59eef7b305b8ca90", - "feeAmount": "0", - "kind": "sell", - "partiallyFillable": false, - "sellTokenBalance": "erc20", - "buyTokenBalance": "erc20", - "signingScheme": "eip712", - "signature": "0x894e427c681f1b4d24604039966321ed59993ce2a1e17fffc742c8af954aa0b10cca77ce750ce60e3d7591b60c90417d333c1d83493abafb8a36d7778e6519a51c", - "interactions": { - "pre": [ - +from utils.visibility import is_hidden + +log = logging.getLogger("rocketwatch.cow_orders") + + +class CoWTradeArgs(TypedDict): + owner: ChecksumAddress + sellToken: ChecksumAddress + buyToken: ChecksumAddress + sellAmount: int + buyAmount: int + feeAmount: int + orderUid: bytes + + +class CoWOrders(EventPlugin): + def __init__(self, bot: RocketWatch) -> None: + super().__init__(bot) + self._settlement: AsyncContract | None = None + self._tokens: list[ChecksumAddress] | None = None + + async def _ensure_setup(self) -> None: + if self._settlement is None: + self._settlement = await rp.get_contract_by_name("GPv2Settlement") + if self._tokens is None: + self._tokens = [ + await rp.get_address_by_name("rocketTokenRPL"), + await rp.get_address_by_name("rocketTokenRETH"), ] - } - }, - """ - # filter all orders that do not contain RPL - cow_orders = [order for order in cow_orders if order["sellToken"] in self.tokens or order["buyToken"] in self.tokens] + @command() + async def cow(self, interaction: Interaction, etherscan_url: str) -> None: + if "etherscan.io/tx/" not in etherscan_url: + await interaction.response.send_message( + "Invalid Etherscan URL", ephemeral=True + ) + return + + await interaction.response.defer(ephemeral=is_hidden(interaction)) + url = etherscan_url.replace("etherscan.io", "explorer.cow.fi") + embed = Embed(description=f"[cow explorer]({url})") + await interaction.followup.send(embed=embed) + + async def _get_new_events(self) -> list[Event]: + from_block = self.last_served_block + 1 - self.lookback_distance + return await self.get_past_events(BlockNumber(from_block), self._pending_block) + + async def get_past_events( + self, from_block: BlockNumber, to_block: BlockNumber + ) -> list[Event]: + await self._ensure_setup() + assert self._settlement is not None + assert self._tokens is not None + + trade_event = self._settlement.events.Trade() + logs = await w3.eth.get_logs( + { + "address": self._settlement.address, + "topics": [trade_event.topic], + "fromBlock": from_block, + "toBlock": to_block, + } + ) + + if not logs: + return [] - # filter all orders that are not open - cow_orders = [order for order in cow_orders if order["executed"] == "0"] + # decode logs into Trade events + trades: list[EventData] = [trade_event.process_log(raw_log) for raw_log in logs] + # filter for RPL and rETH trades + trades = [ + trade + for trade in trades + if trade["args"]["sellToken"] in self._tokens + or trade["args"]["buyToken"] in self._tokens + ] - # efficiently check if the orders are already in the database - order_uids = [order["uid"] for order in cow_orders] - existing_orders = self.collection.find({"order_uid": {"$in": order_uids}}) - existing_order_uids = [order["order_uid"] for order in existing_orders] + if not trades: + return [] - # filter all orders that are already in the database - cow_orders = [order for order in cow_orders if order["uid"] not in existing_order_uids] + # get prices for USD threshold + rpl_ratio = solidity.to_float(await rp.call("rocketNetworkPrices.getRPLPrice")) + reth_ratio = solidity.to_float(await rp.call("rocketTokenRETH.getExchangeRate")) + eth_usdc_price = await rp.get_eth_usdc_price() + rpl_price: float = rpl_ratio * eth_usdc_price + reth_price: float = reth_ratio * eth_usdc_price + + events: list[Event] = [] + for trade in trades: + args = cast(CoWTradeArgs, trade["args"]) + data: aDict[str, Any] = aDict({}) + + data["cow_uid"] = f"0x{args['orderUid'].hex()}" + data["cow_owner"] = w3.to_checksum_address(args["owner"]) + data["transactionHash"] = trade["transactionHash"].to_0x_hex() + + sell_token: ChecksumAddress = args["sellToken"] + buy_token: ChecksumAddress = args["buyToken"] + + if buy_token in self._tokens: + token = "rETH" if buy_token == self._tokens[1] else "RPL" + token_amount, other_amount = args["buyAmount"], args["sellAmount"] + other_address = w3.to_checksum_address(args["sellToken"]) + data["event_name"] = f"cow_order_buy_{token.lower()}" + else: + token = "rETH" if sell_token == self._tokens[1] else "RPL" + token_amount, other_amount = args["sellAmount"], args["buyAmount"] + other_address = w3.to_checksum_address(args["buyToken"]) + data["event_name"] = f"cow_order_sell_{token.lower()}" + + data["ourAmount"] = solidity.to_float(token_amount, 18) + # skip trades under minimum value + if ((token == "RPL") and (data["ourAmount"] * rpl_price < 10_000)) or ( + (token == "rETH") and (data["ourAmount"] * reth_price < 100_000) + ): + continue - if not cow_orders: - return [] - # get rpl price in dai - rpl_ratio = solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice")) - reth_ratio = solidity.to_float(rp.call("rocketTokenRETH.getExchangeRate")) - rpl_price = rpl_ratio * rp.get_eth_usdc_price() - reth_price = reth_ratio * rp.get_eth_usdc_price() - - # generate payloads - for order in cow_orders: - data = aDict({}) - - data["cow_uid"] = order["uid"] - data["cow_owner"] = w3.toChecksumAddress(order["owner"]) decimals = 18 - # base the event_name depending on if its buying or selling RPL - if order["sellToken"] in self.tokens: - token = "reth" if order["sellToken"] == self.tokens[1] else "rpl" - data["event_name"] = f"cow_order_sell_{token}_found" - # token/token ratio - data["ratio"] = int(order["sellAmount"]) / int(order["buyAmount"]) - # store rpl and other token amount - data["ourAmount"] = solidity.to_float(int(order["sellAmount"])) - s = rp.assemble_contract(name="ERC20", address=w3.toChecksumAddress(order["buyToken"])) - try: - decimals = s.functions.decimals().call() - except: - pass - data["otherAmount"] = solidity.to_float(int(order["buyAmount"]), decimals) - else: - token = "reth" if order["buyToken"] == self.tokens[1] else "rpl" - data["event_name"] = f"cow_order_buy_{token}_found" - # store rpl and other token amount - data["ourAmount"] = solidity.to_float(int(order["buyAmount"])) - s = rp.assemble_contract(name="ERC20", address=w3.toChecksumAddress(order["sellToken"])) - try: - decimals = s.functions.decimals().call() - except: - pass - data["otherAmount"] = solidity.to_float(int(order["sellAmount"]), decimals) - # our/other ratio + erc20 = await rp.assemble_contract(name="ERC20", address=other_address) + with contextlib.suppress(Exception): + decimals = await erc20.functions.decimals().call() + + data["otherAmount"] = solidity.to_float(other_amount, decimals) data["ratioAmount"] = data["otherAmount"] / data["ourAmount"] + try: - data["otherToken"] = s.functions.symbol().call() - except: + data["otherToken"] = await erc20.functions.symbol().call() + except Exception: data["otherToken"] = "UNKWN" - if s.address == w3.toChecksumAddress("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"): + if other_address == w3.to_checksum_address( + "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ): data["otherToken"] = "ETH" - data["deadline"] = int(order["validTo"]) - # if the rpl value in usd is less than 25k, ignore it - if data["ourAmount"] * (rpl_price if token == "rpl" else reth_price) < 25000: - continue - - # request more data from the api - try: - t = requests.get(f"https://cow-proxy.invis.workers.dev/mainnet/api/v1/orders/{order['uid']}") - if t.status_code != 200: - log.error(f"Failed to get more data from the cow api for order {order['uid']}: {t.text}") - continue - extra = t.json() - except Exception as e: - log.error(f"Failed to get more data from the cow api for order {order['uid']}: {e}") - continue - if extra: - if extra["invalidated"]: - log.info(f"Order {order['uid']} is invalidated, skipping") - continue - created = datetime.fromisoformat(extra["creationDate"].replace("Z", "+00:00")) - if datetime.now(timezone.utc) - created > timedelta(minutes=15): - log.info(f"Order {order['uid']} is older than 15 minutes, skipping") - continue - data["timestamp"] = int(created.timestamp()) - - - data = prepare_args(data) - embed = assemble(data) - payload.append(Event( - embed=embed, - topic="cow_orders", - block_number=self._pending_block, - event_name=data["event_name"], - unique_id=f"cow_order_found_{order['uid']}" - )) - # don't emit if the db collection is empty - this is to prevent the bot from spamming the channel with stale data - if not self.collection.count_documents({}): - payload = [] - - # insert all new orders into the database - self.collection.insert_many([{"order_uid": order["uid"]} for order in cow_orders]) - - log.debug("Finished Checking Cow Orders") - return payload - - -async def setup(bot): - await bot.add_cog(CowOrders(bot)) + data = await prepare_args(data) + embed = await assemble(data) + events.append( + Event( + embed=embed, + topic="cow_trade", + block_number=BlockNumber(trade["blockNumber"]), + event_name=data["event_name"], + unique_id=f"cow_trade_{trade['transactionHash'].hex()}:{trade['logIndex']}", + transaction_index=trade["transactionIndex"], + event_index=trade["logIndex"], + ) + ) + + return events + + +async def setup(bot: RocketWatch) -> None: + await bot.add_cog(CoWOrders(bot)) diff --git a/rocketwatch/plugins/dao/dao.py b/rocketwatch/plugins/dao/dao.py index 6f81834b..89341330 100644 --- a/rocketwatch/plugins/dao/dao.py +++ b/rocketwatch/plugins/dao/dao.py @@ -1,31 +1,25 @@ import logging - from dataclasses import dataclass -from typing import Literal from operator import attrgetter - -from eth_typing import ChecksumAddress -from tabulate import tabulate +from typing import Literal from discord import Interaction -from discord.app_commands import Choice, command, describe, autocomplete +from discord.app_commands import Choice, autocomplete, command, describe from discord.ext.commands import Cog +from eth_typing import ChecksumAddress +from tabulate import tabulate from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg -from utils.embeds import Embed -from utils.visibility import is_hidden, is_hidden_weak -from utils.dao import DefaultDAO, OracleDAO, SecurityCouncil, ProtocolDAO -from utils.views import PageView -from utils.embeds import el_explorer_url -from utils.event_logs import get_logs from utils.block_time import ts_to_block +from utils.dao import DefaultDAO, OracleDAO, ProtocolDAO, SecurityCouncil +from utils.embeds import Embed, el_explorer_url +from utils.event_logs import get_logs from utils.rocketpool import rp +from utils.views import PageView +from utils.visibility import is_hidden - -log = logging.getLogger("dao") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.dao") class OnchainDAO(Cog): @@ -33,179 +27,234 @@ def __init__(self, bot: RocketWatch): self.bot = bot @staticmethod - def get_dao_votes_embed(dao: DefaultDAO, full: bool) -> Embed: + async def get_dao_votes_embed(dao: DefaultDAO, full: bool) -> Embed: current_proposals: dict[DefaultDAO.ProposalState, list[DefaultDAO.Proposal]] = { dao.ProposalState.Pending: [], dao.ProposalState.Active: [], dao.ProposalState.Succeeded: [], } - for state, ids in dao.get_proposals_by_state().items(): + for state, ids in (await dao.get_proposal_ids_by_state()).items(): if state in current_proposals: - current_proposals[state].extend([dao.fetch_proposal(pid) for pid in ids]) + current_proposals[state].extend( + [await dao.fetch_proposal(pid) for pid in ids] + ) + + parts = [] + for proposal in current_proposals[dao.ProposalState.Pending]: + body = await dao.build_proposal_body( + proposal, + include_proposer=full, + include_votes=False, + include_payload=full, + ) + parts.append( + f"**Proposal #{proposal.id}** - Pending\n```{body}```" + f"Voting starts , ends ." + ) + for proposal in current_proposals[dao.ProposalState.Active]: + body = await dao.build_proposal_body( + proposal, + include_proposer=full, + include_votes=True, + include_payload=full, + ) + parts.append( + f"**Proposal #{proposal.id}** - Active\n```{body}```Voting ends ." + ) + for proposal in current_proposals[dao.ProposalState.Succeeded]: + body = await dao.build_proposal_body( + proposal, + include_proposer=full, + include_votes=full, + include_payload=full, + ) + parts.append( + f"**Proposal #{proposal.id}** - Succeeded (Not Yet Executed)\n```{body}```Expires ." + ) return Embed( title=f"{dao.display_name} Proposals", - description="\n\n".join( - [ - ( - f"**Proposal #{proposal.id}** - Pending\n" - f"```{dao.build_proposal_body(proposal, include_proposer=full, include_votes=False, include_payload=full)}```" - f"Voting starts , ends ." - ) for proposal in current_proposals[dao.ProposalState.Pending] - ] + [ - ( - f"**Proposal #{proposal.id}** - Active\n" - f"```{dao.build_proposal_body(proposal, include_proposer=full, include_votes=True, include_payload=full)}```" - f"Voting ends ." - ) for proposal in current_proposals[dao.ProposalState.Active] - ] + [ - ( - f"**Proposal #{proposal.id}** - Succeeded (Not Yet Executed)\n" - f"```{dao.build_proposal_body(proposal, include_proposer=full, include_votes=full, include_payload=full)}```" - f"Expires ." - ) for proposal in current_proposals[dao.ProposalState.Succeeded] - ] - ) or "No active proposals." + description="\n\n".join(parts) or "No active proposals.", ) @staticmethod - def get_pdao_votes_embed(dao: ProtocolDAO, full: bool) -> Embed: - current_proposals: dict[ProtocolDAO.ProposalState, list[ProtocolDAO.Proposal]] = { + async def get_pdao_votes_embed(dao: ProtocolDAO, full: bool) -> Embed: + current_proposals: dict[ + ProtocolDAO.ProposalState, list[ProtocolDAO.Proposal] + ] = { dao.ProposalState.Pending: [], dao.ProposalState.ActivePhase1: [], dao.ProposalState.ActivePhase2: [], dao.ProposalState.Succeeded: [], } - for state, ids in dao.get_proposals_by_state().items(): + for state, ids in (await dao.get_proposal_ids_by_state()).items(): if state in current_proposals: - current_proposals[state].extend([dao.fetch_proposal(pid) for pid in ids]) + current_proposals[state].extend( + [await dao.fetch_proposal(pid) for pid in ids] + ) + + parts = [] + for proposal in current_proposals[dao.ProposalState.Pending]: + body = await dao.build_proposal_body( + proposal, + include_proposer=full, + include_votes=False, + include_payload=full, + ) + parts.append( + f"**Proposal #{proposal.id}** - Pending\n```{body}```" + f"Voting starts , ends ." + ) + for proposal in current_proposals[dao.ProposalState.ActivePhase1]: + body = await dao.build_proposal_body( + proposal, + include_proposer=full, + include_votes=True, + include_payload=full, + ) + parts.append( + f"**Proposal #{proposal.id}** - Active (Phase 1)\n```{body}```" + f"Next phase , voting ends ." + ) + for proposal in current_proposals[dao.ProposalState.ActivePhase2]: + body = await dao.build_proposal_body( + proposal, + include_proposer=full, + include_votes=True, + include_payload=full, + ) + parts.append( + f"**Proposal #{proposal.id}** - Active (Phase 2)\n```{body}```Voting ends ." + ) + for proposal in current_proposals[dao.ProposalState.Succeeded]: + body = await dao.build_proposal_body( + proposal, + include_proposer=full, + include_votes=full, + include_payload=full, + ) + parts.append( + f"**Proposal #{proposal.id}** - Succeeded (Not Yet Executed)\n```{body}```Expires ." + ) return Embed( title="pDAO Proposals", - description="\n\n".join( - [ - ( - f"**Proposal #{proposal.id}** - Pending\n" - f"```{dao.build_proposal_body(proposal, include_proposer=full, include_votes=False, include_payload=full)}```" - f"Voting starts , ends ." - ) for proposal in current_proposals[dao.ProposalState.Pending] - ] + [ - ( - f"**Proposal #{proposal.id}** - Active (Phase 1)\n" - f"```{dao.build_proposal_body(proposal, include_proposer=full, include_votes=True, include_payload=full)}```" - f"Next phase , voting ends ." - ) for proposal in current_proposals[dao.ProposalState.ActivePhase1] - ] + [ - ( - f"**Proposal #{proposal.id}** - Active (Phase 2)\n" - f"```{dao.build_proposal_body(proposal, include_proposer=full, include_votes=True, include_payload=full)}```" - f"Voting ends ." - ) for proposal in current_proposals[dao.ProposalState.ActivePhase2] - ] + [ - ( - f"**Proposal #{proposal.id}** - Succeeded (Not Yet Executed)\n" - f"```{dao.build_proposal_body(proposal, include_proposer=full, include_votes=full, include_payload=full)}```" - f"Expires ." - ) for proposal in current_proposals[dao.ProposalState.Succeeded] - ] - ) or "No active proposals." + description="\n\n".join(parts) or "No active proposals.", ) @command() @describe(dao_name="DAO to show proposals for") @describe(full="show all information (e.g. payload)") async def dao_votes( - self, - interaction: Interaction, - dao_name: Literal["oDAO", "pDAO", "Security Council"] = "pDAO", - full: bool = False + self, + interaction: Interaction, + dao_name: Literal["oDAO", "pDAO", "Security Council"] = "pDAO", + full: bool = False, ) -> None: """Show currently active on-chain proposals""" - visibility = is_hidden(interaction) if full else is_hidden_weak(interaction) - await interaction.response.defer(ephemeral=visibility) + await interaction.response.defer(ephemeral=is_hidden(interaction)) match dao_name: case "pDAO": dao = ProtocolDAO() - embed = self.get_pdao_votes_embed(dao, full) + embed = await self.get_pdao_votes_embed(dao, full) case "oDAO": dao = OracleDAO() - embed = self.get_dao_votes_embed(dao, full) + embed = await self.get_dao_votes_embed(dao, full) case "Security Council": dao = SecurityCouncil() - embed = self.get_dao_votes_embed(dao, full) + embed = await self.get_dao_votes_embed(dao, full) case _: raise ValueError(f"Invalid DAO name: {dao_name}") await interaction.followup.send(embed=embed) - + @dataclass(slots=True) class Vote: voter: ChecksumAddress direction: int voting_power: float - time: int + time: int class VoterPageView(PageView): def __init__(self, proposal: ProtocolDAO.Proposal): super().__init__(page_size=25) self.proposal = proposal - self._voter_list = self._get_voter_list(proposal) - - def _get_voter_list(self, proposal: ProtocolDAO.Proposal) -> list['OnchainDAO.Vote']: + self._voter_list: list[OnchainDAO.Vote] | None = None + + async def _ensure_voter_list(self): + if self._voter_list is not None: + return + self._voter_list = await self._get_voter_list(self.proposal) + + async def _get_voter_list( + self, proposal: ProtocolDAO.Proposal + ) -> list["OnchainDAO.Vote"]: voters: dict[ChecksumAddress, OnchainDAO.Vote] = {} dao = ProtocolDAO() - - for vote_log in get_logs( - dao.proposal_contract.events.ProposalVoted, - ts_to_block(proposal.start) - 1, - ts_to_block(proposal.end_phase_2) + 1, - {"proposalID": proposal.id} + proposal_contract = await dao._get_proposal_contract() + + for vote_log in await get_logs( + proposal_contract.events.ProposalVoted, + await ts_to_block(proposal.start) - 1, + await ts_to_block(proposal.end_phase_2) + 1, + {"proposalID": proposal.id}, ): vote = OnchainDAO.Vote( - vote_log.args.voter, + vote_log.args.voter, vote_log.args.direction, solidity.to_float(vote_log.args.votingPower), - vote_log.args.time + vote_log.args.time, ) voters[vote.voter] = vote - - for override_log in get_logs( - dao.proposal_contract.events.ProposalVoteOverridden, - ts_to_block(proposal.end_phase_1) - 1, - ts_to_block(proposal.end_phase_2) + 1, - {"proposalID": proposal.id} + + for override_log in await get_logs( + proposal_contract.events.ProposalVoteOverridden, + await ts_to_block(proposal.end_phase_1) - 1, + await ts_to_block(proposal.end_phase_2) + 1, + {"proposalID": proposal.id}, ): voting_power = solidity.to_float(override_log.args.votingPower) voters[override_log.args.delegate].voting_power -= voting_power - + return sorted(voters.values(), key=attrgetter("voting_power"), reverse=True) - + @property def _title(self) -> str: return f"pDAO Proposal #{self.proposal.id} - Voter List" - - async def _load_content(self, from_idx: int, to_idx: int) -> tuple[int, str]: + + async def _load_content(self, from_idx: int, to_idx: int) -> tuple[int, str]: + await self._ensure_voter_list() + assert self._voter_list is not None headers = ["#", "Voter", "Choice", "Weight"] data = [] - for i, voter in enumerate(self._voter_list[from_idx:(to_idx + 1)], start=from_idx): - name = el_explorer_url(voter.voter, prefix=-1).split("[")[1].split("]")[0] + for i, voter in enumerate( + self._voter_list[from_idx : (to_idx + 1)], start=from_idx + ): + name = ( + (await el_explorer_url(voter.voter, prefix=None)) + .split("[")[1] + .split("]")[0] + ) vote = ["", "Abstain", "For", "Against", "Veto"][voter.direction] voting_power = f"{voter.voting_power:,.2f}" - data.append([i+1, name, vote, voting_power]) - + data.append([i + 1, name, vote, voting_power]) + if not data: return 0, "" - + table = tabulate(data, headers, colalign=("right", "left", "left", "right")) return len(self._voter_list), f"```{table}```" - - async def _get_recent_proposals(self, interaction: Interaction, current: str) -> list[Choice[int]]: + + async def _get_recent_proposals( + self, interaction: Interaction, current: str + ) -> list[Choice[int]]: dao = ProtocolDAO() - num_proposals = dao.proposal_contract.functions.getTotal().call() - + proposal_contract = await dao._get_proposal_contract() + num_proposals = await proposal_contract.functions.getTotal().call() + if current: try: suggestions = [int(current)] @@ -214,23 +263,27 @@ async def _get_recent_proposals(self, interaction: Interaction, current: str) -> return [] else: suggestions = list(range(1, num_proposals + 1))[:-26:-1] - - titles: list[str] = [ - res.results[0] for res in rp.multicall.aggregate([ - dao.proposal_contract.functions.getMessage(proposal_id) for proposal_id in suggestions - ]).results + + titles: list[str] = await rp.multicall( + [ + proposal_contract.functions.getMessage(proposal_id) + for proposal_id in suggestions + ] + ) + return [ + Choice(name=f"#{pid}: {title}", value=pid) + for pid, title in zip(suggestions, titles, strict=False) ] - return [Choice(name=f"#{pid}: {title}", value=pid) for pid, title in zip(suggestions, titles)] - + @command() @describe(proposal="proposal to show voters for") @autocomplete(proposal=_get_recent_proposals) async def voter_list(self, interaction: Interaction, proposal: int) -> None: """Show the list of voters for a pDAO proposal""" - await interaction.response.defer(ephemeral=is_hidden_weak(interaction)) - if not (proposal := ProtocolDAO().fetch_proposal(proposal)): + await interaction.response.defer(ephemeral=is_hidden(interaction)) + if not (proposal := await ProtocolDAO().fetch_proposal(proposal)): return await interaction.followup.send("Invalid proposal ID.") - + view = OnchainDAO.VoterPageView(proposal) embed = await view.load() await interaction.followup.send(embed=embed, view=view) diff --git a/rocketwatch/plugins/db_upkeep_task/db_upkeep_task.py b/rocketwatch/plugins/db_upkeep_task/db_upkeep_task.py new file mode 100644 index 00000000..c0e0457d --- /dev/null +++ b/rocketwatch/plugins/db_upkeep_task/db_upkeep_task.py @@ -0,0 +1,956 @@ +import asyncio +import logging +import time +from collections import defaultdict +from collections.abc import Callable, Coroutine +from datetime import timedelta +from typing import Any + +import pymongo +from cronitor import Monitor +from discord.ext import commands +from discord.utils import as_chunks +from eth_typing import BlockNumber +from pymongo import UpdateMany, UpdateOne +from pymongo.asynchronous.collection import AsyncCollection + +from rocketwatch import RocketWatch +from utils import solidity +from utils.block_time import ts_to_block +from utils.config import cfg +from utils.event_logs import get_logs +from utils.rocketpool import rp +from utils.shared_w3 import bacon, w3 +from utils.time_debug import timerun, timerun_async + +log = logging.getLogger("rocketwatch.db_upkeep_task") + + +def is_true(v) -> bool: + return v is True + + +def safe_to_float(num): + try: + return solidity.to_float(num) + except Exception: + return None + + +def safe_to_hex(b: bytes) -> str | None: + return f"0x{b.hex()}" if b else None + + +def safe_state_to_str(state): + try: + return solidity.mp_state_to_str(state) + except Exception: + return None + + +def safe_inv(num): + try: + return 1 / solidity.to_float(num) + except Exception: + return None + + +def _parse_epoch(value): + epoch = int(value) + return epoch if epoch < 2**32 else None + + +def _derive_validator_status(info): + if info[9]: # dissolved + return "dissolved" + if info[5]: # exited + return "exited" + if info[6]: # inQueue + return "in_queue" + if info[7]: # inPrestake + return "prestaked" + if info[11]: # locked + return "locked" + if info[10]: # exiting + return "exiting" + if info[4]: # staked + return "staking" + return "unknown" + + +def _unpack_validator_info(info): + if info is None: + return None + return { + "status": _derive_validator_status(info), + "express_used": info[8], + "assignment_time": info[0], + "requested_bond": info[2] / 1000, # milliether to ETH + "deposit_value": info[3] / 1000, # milliether to ETH + "exit_balance": solidity.to_float(info[12], 9), # gwei to ETH + } + + +def _unpack_validator_info_dynamic(info): + if info is None: + return None + return { + "status": _derive_validator_status(info), + "assignment_time": info[0], + "requested_bond": info[2] / 1000, + "deposit_value": info[3] / 1000, + "exit_balance": solidity.to_float(info[12], 9), + } + + +class DBUpkeepTask(commands.Cog): + def __init__(self, bot: RocketWatch): + self.bot = bot + self.monitor = Monitor("db-task", api_key=cfg.other.secrets.cronitor) + self.batch_size = 250 + self.cooldown = timedelta(minutes=10) + self.bot.loop.create_task(self.loop()) + + async def loop(self): + await self.bot.wait_until_ready() + await self.check_indexes() + while not self.bot.is_closed(): + p_id = time.time() + self.monitor.ping(state="run", series=p_id) + try: + log.debug("starting db upkeep task") + # node operator tasks + await self.add_untracked_node_operators() + await self.add_static_node_operator_data() + await self.update_dynamic_node_operator_data() + await self.update_dynamic_megapool_data() + # minipool tasks + await self.add_untracked_minipools() + await self.add_static_minipool_data() + await self.add_static_minipool_deposit_data() + await self.update_dynamic_minipool_data() + await self.update_dynamic_minipool_beacon_data() + # megapool validator tasks + await self.add_untracked_megapool_validators() + await self.add_static_megapool_deposit_data() + await self.update_dynamic_megapool_validator_data() + await self.update_dynamic_megapool_validator_beacon_data() + log.debug("finished db upkeep task") + self.monitor.ping(state="complete", series=p_id) + except Exception as err: + self.monitor.ping(state="fail", series=p_id) + await self.bot.report_error(err) + finally: + await asyncio.sleep(self.cooldown.total_seconds()) + + async def check_indexes(self): + log.debug("checking indexes") + await self.bot.db.node_operators.create_index("address") + await self.bot.db.node_operators.create_index("megapool.address") + await self.bot.db.minipools.create_index("address") + await self.bot.db.minipools.create_index("pubkey") + await self.bot.db.minipools.create_index("validator_index") + await self.bot.db.minipools.create_index("beacon.status") + await self.bot.db.megapool_validators.create_index( + [("megapool", pymongo.ASCENDING), ("validator_id", pymongo.ASCENDING)], + unique=True, + ) + await self.bot.db.megapool_validators.create_index("pubkey") + await self.bot.db.megapool_validators.create_index("validator_index") + await self.bot.db.megapool_validators.create_index("status") + await self.bot.db.megapool_validators.create_index("beacon.status") + log.debug("indexes checked") + + async def _batch_multicall_update( + self, + collection: AsyncCollection, + query: dict[str, Any], + call_fn: Callable[[dict[str, Any]], Coroutine[Any, Any, list[tuple]]], + projection: dict[str, Any], + label: str | None, + ) -> None: + items = await collection.find(query, projection).to_list() + if not items: + return + + total = len(items) + first_calls = await call_fn(items[0]) + batch_size = self.batch_size // len(first_calls) + for i, batch in enumerate(as_chunks(items, batch_size)): + if label: + start = i * batch_size + 1 + end = min((i + 1) * batch_size, total) + log.debug(f"Processing {label} [{start}, {end}]/{total}") + # call_fn(item) returns a list of (fn, require_success, transform, field) + expanded = [] + for item in batch: + for t in await call_fn(item): + expanded.append((item["address"], *t)) + calls = [(e[1], e[2]) for e in expanded] + results = await rp.multicall(calls) + updates: dict[Any, dict[str, Any]] = defaultdict(dict) + for e, value in zip(expanded, results, strict=False): + addr, transform, field = e[0], e[3], e[4] + if transform is not None and value is not None: + value = transform(value) + updates[addr][field] = value + await collection.bulk_write( + [ + UpdateOne({"address": addr}, {"$set": d}) + for addr, d in updates.items() + ], + ordered=False, + ) + + # -- Node operator tasks -- + + @timerun_async + async def add_untracked_node_operators(self): + nm = await rp.get_contract_by_name("rocketNodeManager") + latest_rp = await rp.call("rocketNodeManager.getNodeCount") - 1 + latest_db = 0 + if res := await self.bot.db.node_operators.find_one( + sort=[("_id", pymongo.DESCENDING)] + ): + latest_db = res["_id"] + if latest_db >= latest_rp: + log.debug("No new nodes") + return + data: dict[int, Any] = {} + for index_batch in as_chunks( + range(latest_db + 1, latest_rp + 1), self.batch_size + ): + results = await rp.multicall( + [nm.functions.getNodeAt(i) for i in index_batch] + ) + data |= dict(zip(index_batch, results, strict=False)) + await self.bot.db.node_operators.insert_many( + [{"_id": i, "address": w3.to_checksum_address(a)} for i, a in data.items()] + ) + + @timerun_async + async def add_static_node_operator_data(self): + df = await rp.get_contract_by_name("rocketNodeDistributorFactory") + mf = await rp.get_contract_by_name("rocketMegapoolFactory") + + async def get_calls(n): + return [ + ( + df.functions.getProxyAddress(n["address"]), + True, + w3.to_checksum_address, + "fee_distributor.address", + ), + ( + mf.functions.getExpectedAddress(n["address"]), + True, + w3.to_checksum_address, + "megapool.address", + ), + ] + + await self._batch_multicall_update( + self.bot.db.node_operators, + { + "$or": [ + {"fee_distributor.address": {"$exists": False}}, + {"megapool.address": {"$exists": False}}, + ] + }, + get_calls, + {"address": 1}, + label="node operators", + ) + + @timerun_async + async def update_dynamic_node_operator_data(self): + mf = await rp.get_contract_by_name("rocketMegapoolFactory") + nd = await rp.get_contract_by_name("rocketNodeDeposit") + nm = await rp.get_contract_by_name("rocketNodeManager") + mm = await rp.get_contract_by_name("rocketMinipoolManager") + ns = await rp.get_contract_by_name("rocketNodeStaking") + mc = await rp.get_contract_by_name("multicall3") + + async def get_calls(n): + return [ + ( + nm.functions.getNodeWithdrawalAddress(n["address"]), + True, + w3.to_checksum_address, + "withdrawal_address", + ), + ( + nm.functions.getNodeTimezoneLocation(n["address"]), + True, + None, + "timezone_location", + ), + ( + nm.functions.getSmoothingPoolRegistrationState(n["address"]), + True, + None, + "smoothing_pool_registration", + ), + ( + nm.functions.getAverageNodeFee(n["address"]), + True, + safe_to_float, + "average_node_fee", + ), + ( + ns.functions.getNodeETHCollateralisationRatio(n["address"]), + True, + safe_inv, + "effective_node_share", + ), + ( + mm.functions.getNodeStakingMinipoolCount(n["address"]), + True, + None, + "staking_minipool_count", + ), + ( + nd.functions.getNodeDepositCredit(n["address"]), + True, + safe_to_float, + "node_credit", + ), + ( + nd.functions.getNodeEthBalance(n["address"]), + True, + safe_to_float, + "node_eth_balance", + ), + ( + nm.functions.getFeeDistributorInitialised(n["address"]), + True, + None, + "fee_distributor.initialized", + ), + ( + mc.functions.getEthBalance(n["fee_distributor"]["address"]), + True, + safe_to_float, + "fee_distributor.eth_balance", + ), + ( + mf.functions.getMegapoolDeployed(n["address"]), + True, + None, + "megapool.deployed", + ), + ( + mc.functions.getEthBalance(n["megapool"]["address"]), + True, + safe_to_float, + "megapool.eth_balance", + ), + ( + ns.functions.getNodeStakedRPL(n["address"]), + True, + safe_to_float, + "rpl.total_stake", + ), + ( + ns.functions.getNodeLegacyStakedRPL(n["address"]), + True, + safe_to_float, + "rpl.legacy_stake", + ), + ( + ns.functions.getNodeMegapoolStakedRPL(n["address"]), + True, + safe_to_float, + "rpl.megapool_stake", + ), + ( + ns.functions.getNodeLockedRPL(n["address"]), + True, + safe_to_float, + "rpl.locked", + ), + ( + ns.functions.getNodeUnstakingRPL(n["address"]), + True, + safe_to_float, + "rpl.unstaking", + ), + ( + ns.functions.getNodeRPLStakedTime(n["address"]), + True, + None, + "rpl.last_stake_time", + ), + ( + ns.functions.getNodeLastUnstakeTime(n["address"]), + True, + None, + "rpl.last_unstake_time", + ), + ] + + await self._batch_multicall_update( + self.bot.db.node_operators, + {}, + get_calls, + label="node operators", + projection={ + "address": 1, + "fee_distributor.address": 1, + "megapool.address": 1, + }, + ) + + @timerun_async + async def update_dynamic_megapool_data(self): + async def get_calls(n): + mp = await rp.assemble_contract( + "rocketMegapoolDelegate", address=n["megapool"]["address"] + ) + proxy = await rp.assemble_contract( + "rocketMegapoolProxy", address=n["megapool"]["address"] + ) + return [ + ( + mp.functions.getValidatorCount(), + True, + None, + "megapool.validator_count", + ), + ( + mp.functions.getActiveValidatorCount(), + True, + None, + "megapool.active_validator_count", + ), + ( + mp.functions.getExitingValidatorCount(), + True, + None, + "megapool.exiting_validator_count", + ), + ( + mp.functions.getLockedValidatorCount(), + True, + None, + "megapool.locked_validator_count", + ), + (mp.functions.getNodeBond(), True, safe_to_float, "megapool.node_bond"), + ( + mp.functions.getUserCapital(), + True, + safe_to_float, + "megapool.user_capital", + ), + (mp.functions.getDebt(), True, safe_to_float, "megapool.debt"), + ( + mp.functions.getRefundValue(), + True, + safe_to_float, + "megapool.refund_value", + ), + ( + mp.functions.getPendingRewards(), + True, + safe_to_float, + "megapool.pending_rewards", + ), + ( + mp.functions.getLastDistributionTime(), + True, + None, + "megapool.last_distribution_time", + ), + ( + proxy.functions.getDelegate(), + True, + w3.to_checksum_address, + "megapool.delegate", + ), + ( + proxy.functions.getEffectiveDelegate(), + True, + w3.to_checksum_address, + "megapool.effective_delegate", + ), + ( + proxy.functions.getUseLatestDelegate(), + True, + None, + "megapool.use_latest_delegate", + ), + ] + + await self._batch_multicall_update( + self.bot.db.node_operators, + {"megapool.deployed": True}, + get_calls, + {"address": 1, "megapool.address": 1}, + label="megapools", + ) + + # -- Minipool tasks -- + + @timerun_async + async def add_untracked_minipools(self): + mm = await rp.get_contract_by_name("rocketMinipoolManager") + latest_rp = await rp.call("rocketMinipoolManager.getMinipoolCount") - 1 + latest_db = 0 + if res := await self.bot.db.minipools.find_one( + sort=[("_id", pymongo.DESCENDING)] + ): + latest_db = res["_id"] + if latest_db >= latest_rp: + log.debug("No new minipools") + return + log.debug( + f"Latest minipool in db: {latest_db}, latest minipool in rp: {latest_rp}" + ) + for index_batch in as_chunks( + range(latest_db + 1, latest_rp + 1), self.batch_size + ): + results = await rp.multicall( + [mm.functions.getMinipoolAt(i) for i in index_batch] + ) + await self.bot.db.minipools.insert_many( + [ + {"_id": i, "address": w3.to_checksum_address(a)} + for i, a in zip(index_batch, results, strict=False) + ] + ) + + @timerun_async + async def add_static_minipool_data(self): + mm = await rp.get_contract_by_name("rocketMinipoolManager") + + async def lamb(n): + return [ + ( + ( + await rp.assemble_contract( + "rocketMinipool", address=n["address"] + ) + ).functions.getNodeAddress(), + True, + w3.to_checksum_address, + "node_operator", + ), + ( + mm.functions.getMinipoolPubkey(n["address"]), + True, + safe_to_hex, + "pubkey", + ), + ] + + await self._batch_multicall_update( + self.bot.db.minipools, + {"node_operator": {"$exists": False}}, + lamb, + {"address": 1}, + label="minipools", + ) + + @timerun + async def add_static_minipool_deposit_data(self): + minipools = ( + await self.bot.db.minipools.find( + {"deposit_amount": {"$exists": False}, "status": "initialised"}, + {"address": 1, "_id": 0, "status_time": 1}, + ) + .sort("status_time", pymongo.ASCENDING) + .to_list() + ) + if not minipools: + return + nd = await rp.get_contract_by_name("rocketNodeDeposit") + mm = await rp.get_contract_by_name("rocketMinipoolManager") + + for minipool_batch in as_chunks(minipools, self.batch_size): + block_start = BlockNumber( + await ts_to_block(minipool_batch[0]["status_time"]) - 1 + ) + block_end = BlockNumber( + await ts_to_block(minipool_batch[-1]["status_time"]) + 1 + ) + log.debug(f"Processing deposit data for blocks {block_start}..{block_end}") + addresses = {m["address"] for m in minipool_batch} + + events = await get_logs( + nd.events.DepositReceived, block_start, block_end + ) + await get_logs(mm.events.MinipoolCreated, block_start, block_end) + events.sort( + key=lambda e: (e["blockNumber"], e["transactionIndex"], e["logIndex"]), + reverse=True, + ) + + # pair DepositReceived + MinipoolCreated events from same transaction + pairs = [] + last_is_creation = False + for e in events: + if e["event"] == "MinipoolCreated": + if not last_is_creation: + pairs.append([e]) + else: + pairs[-1] = [e] + log.info( + f"replacing creation event with newly found one ({pairs[-1]})" + ) + elif e["event"] == "DepositReceived" and last_is_creation: + pairs[-1].insert(0, e) + last_is_creation = e["event"] == "MinipoolCreated" + + data = {} + for pair in pairs: + assert "amount" in pair[0]["args"] + assert "minipool" in pair[1]["args"] + assert pair[0]["transactionHash"] == pair[1]["transactionHash"] + mp = str(pair[1]["args"]["minipool"]).lower() + if mp in addresses: + data[mp] = { + "deposit_amount": solidity.to_float(pair[0]["args"]["amount"]) + } + + if not data: + continue + await self.bot.db.minipools.bulk_write( + [UpdateOne({"address": addr}, {"$set": d}) for addr, d in data.items()], + ordered=False, + ) + + @timerun_async + async def update_dynamic_minipool_data(self): + mc = await rp.get_contract_by_name("multicall3") + + async def get_calls(n): + minipool_contract = await rp.assemble_contract( + "rocketMinipool", address=n["address"] + ) + return [ + ( + minipool_contract.functions.getStatus(), + True, + safe_state_to_str, + "status", + ), + ( + minipool_contract.functions.getStatusTime(), + True, + None, + "status_time", + ), + (minipool_contract.functions.getVacant(), False, is_true, "vacant"), + ( + minipool_contract.functions.getFinalised(), + True, + is_true, + "finalized", + ), + ( + minipool_contract.functions.getNodeDepositBalance(), + True, + safe_to_float, + "node_deposit_balance", + ), + ( + minipool_contract.functions.getNodeRefundBalance(), + True, + safe_to_float, + "node_refund_balance", + ), + ( + minipool_contract.functions.getPreMigrationBalance(), + False, + safe_to_float, + "pre_migration_balance", + ), + ( + minipool_contract.functions.getNodeFee(), + True, + safe_to_float, + "node_fee", + ), + ( + minipool_contract.functions.getDelegate(), + True, + w3.to_checksum_address, + "delegate", + ), + ( + minipool_contract.functions.getPreviousDelegate(), + False, + w3.to_checksum_address, + "previous_delegate", + ), + ( + minipool_contract.functions.getEffectiveDelegate(), + True, + w3.to_checksum_address, + "effective_delegate", + ), + ( + minipool_contract.functions.getUseLatestDelegate(), + True, + is_true, + "use_latest_delegate", + ), + ( + minipool_contract.functions.getUserDistributed(), + False, + is_true, + "user_distributed", + ), + ( + mc.functions.getEthBalance(n["address"]), + True, + safe_to_float, + "execution_balance", + ), + ] + + await self._batch_multicall_update( + self.bot.db.minipools, + {"finalized": {"$ne": True}}, + get_calls, + {"address": 1}, + label="minipools", + ) + + @timerun + async def update_dynamic_minipool_beacon_data(self): + pubkeys = await self.bot.db.minipools.distinct( + "pubkey", {"beacon.status": {"$ne": "withdrawal_done"}} + ) + pubkeys = [pk for pk in pubkeys if pk is not None] + total = len(pubkeys) + for i, pubkey_batch in enumerate(as_chunks(pubkeys, self.batch_size)): + start = i * self.batch_size + 1 + end = min((i + 1) * self.batch_size, total) + log.info( + f"Updating beacon chain data for minipools [{start}, {end}]/{total}" + ) + beacon_data = (await bacon.get_validators_by_ids("head", ids=pubkey_batch))[ + "data" + ] + data = {} + for d in beacon_data: + v = d["validator"] + data[v["pubkey"]] = { + "validator_index": int(d["index"]), + "beacon": { + "status": d["status"], + "balance": solidity.to_float(d["balance"], 9), + "effective_balance": solidity.to_float( + v["effective_balance"], 9 + ), + "slashed": v["slashed"], + "activation_eligibility_epoch": _parse_epoch( + v["activation_eligibility_epoch"] + ), + "activation_epoch": _parse_epoch(v["activation_epoch"]), + "exit_epoch": _parse_epoch(v["exit_epoch"]), + "withdrawable_epoch": _parse_epoch(v["withdrawable_epoch"]), + }, + } + if data: + await self.bot.db.minipools.bulk_write( + [UpdateMany({"pubkey": pk}, {"$set": d}) for pk, d in data.items()], + ordered=False, + ) + + # -- Megapool validator tasks -- + + @timerun_async + async def add_untracked_megapool_validators(self): + # get deployed megapools with their on-chain validator count + nodes = await self.bot.db.node_operators.find( + {"megapool.deployed": True, "megapool.validator_count": {"$gt": 0}}, + {"address": 1, "megapool.address": 1, "megapool.validator_count": 1}, + ).to_list() + if not nodes: + return + + for node in nodes: + megapool_addr = node["megapool"]["address"] + on_chain_count = node["megapool"]["validator_count"] + db_count = await self.bot.db.megapool_validators.count_documents( + {"megapool": megapool_addr} + ) + if db_count >= on_chain_count: + continue + + new_ids = list(range(db_count, on_chain_count)) + log.debug( + f"Adding {len(new_ids)} new validators for megapool {megapool_addr}" + ) + + megapool_contract = await rp.assemble_contract( + "rocketMegapoolDelegate", address=megapool_addr + ) + for id_batch in as_chunks(new_ids, self.batch_size // 2): + fns = [ + fn + for vid in id_batch + for fn in [ + megapool_contract.functions.getValidatorPubkey(vid), + megapool_contract.functions.getValidatorInfo(vid), + ] + ] + results = await rp.multicall(fns) + + docs = [] + for i, vid in enumerate(id_batch): + pubkey_raw = results[i * 2] + info_raw = results[i * 2 + 1] + doc = { + "megapool": megapool_addr, + "node_operator": node["address"], + "validator_id": vid, + "pubkey": safe_to_hex(pubkey_raw) + if pubkey_raw is not None + else None, + } + info = _unpack_validator_info(info_raw) + if info: + doc.update(info) + docs.append(doc) + if docs: + await self.bot.db.megapool_validators.insert_many( + docs, ordered=False + ) + + @timerun_async + async def add_static_megapool_deposit_data(self): + validators = await self.bot.db.megapool_validators.find( + {"deposit_time": {"$exists": False}}, + {"megapool": 1, "validator_id": 1}, + ).to_list() + if not validators: + return + + dp = await rp.get_contract_by_name("rocketDepositPool") + saturn_upgrade_block = BlockNumber(24_479_994) + to_block = await w3.eth.get_block_number() + + by_megapool = defaultdict(list) + for v in validators: + by_megapool[v["megapool"]].append(v) + + for megapool_addr, megapool_validators in by_megapool.items(): + min_vid = min(v["validator_id"] for v in megapool_validators) + if min_vid > 0: + prev = await self.bot.db.megapool_validators.find_one( + {"megapool": megapool_addr, "validator_id": min_vid - 1}, + {"deposit_time": 1}, + ) + from_block = ( + await ts_to_block(prev["deposit_time"]) + if prev and prev.get("deposit_time") + else saturn_upgrade_block + ) + else: + from_block = saturn_upgrade_block + + events = await get_logs( + dp.events.FundsRequested, + from_block, + to_block, + arg_filters={"receiver": megapool_addr}, + ) + events_by_vid = {e["args"]["validatorId"]: e for e in events} + + ops = [] + for v in megapool_validators: + if not (event := events_by_vid.get(v["validator_id"])): + continue + ops.append( + UpdateOne( + {"_id": v["_id"]}, + {"$set": {"deposit_time": event["args"]["time"]}}, + ) + ) + if ops: + await self.bot.db.megapool_validators.bulk_write(ops, ordered=False) + + @timerun_async + async def update_dynamic_megapool_validator_data(self): + validators = await self.bot.db.megapool_validators.find( + {"status": {"$nin": ["exited", "dissolved"]}}, + {"megapool": 1, "validator_id": 1}, + ).to_list() + if not validators: + return + + total = len(validators) + for i, batch in enumerate(as_chunks(validators, self.batch_size)): + start = i * self.batch_size + 1 + end = min((i + 1) * self.batch_size, total) + log.debug(f"Processing megapool validators [{start}, {end}]/{total}") + fns = [ + ( + await rp.assemble_contract( + "rocketMegapoolDelegate", address=v["megapool"] + ) + ).functions.getValidatorInfo(v["validator_id"]) + for v in batch + ] + results = await rp.multicall(fns) + ops = [] + for v, info_raw in zip(batch, results, strict=False): + info = ( + _unpack_validator_info_dynamic(info_raw) + if info_raw is not None + else None + ) + if info is not None: + ops.append(UpdateOne({"_id": v["_id"]}, {"$set": info})) + if ops: + await self.bot.db.megapool_validators.bulk_write(ops, ordered=False) + + @timerun + async def update_dynamic_megapool_validator_beacon_data(self): + pubkeys = await self.bot.db.megapool_validators.distinct( + "pubkey", {"beacon.status": {"$ne": "withdrawal_done"}} + ) + pubkeys = [pk for pk in pubkeys if pk is not None] + if not pubkeys: + return + total = len(pubkeys) + for i, pubkey_batch in enumerate(as_chunks(pubkeys, self.batch_size)): + start = i * self.batch_size + 1 + end = min((i + 1) * self.batch_size, total) + log.debug( + f"Updating beacon data for megapool validators [{start}, {end}]/{total}" + ) + beacon_data = (await bacon.get_validators_by_ids("head", ids=pubkey_batch))[ + "data" + ] + data = {} + for d in beacon_data: + v = d["validator"] + data[v["pubkey"]] = { + "validator_index": int(d["index"]), + "beacon": { + "status": d["status"], + "balance": solidity.to_float(d["balance"], 9), + "effective_balance": solidity.to_float( + v["effective_balance"], 9 + ), + "slashed": v["slashed"], + "activation_eligibility_epoch": _parse_epoch( + v["activation_eligibility_epoch"] + ), + "activation_epoch": _parse_epoch(v["activation_epoch"]), + "exit_epoch": _parse_epoch(v["exit_epoch"]), + "withdrawable_epoch": _parse_epoch(v["withdrawable_epoch"]), + }, + } + if data: + await self.bot.db.megapool_validators.bulk_write( + [UpdateMany({"pubkey": pk}, {"$set": d}) for pk, d in data.items()], + ordered=False, + ) + + +async def setup(self): + await self.add_cog(DBUpkeepTask(self)) diff --git a/rocketwatch/plugins/debug/debug.py b/rocketwatch/plugins/debug/debug.py index d09b4c94..a8595987 100644 --- a/rocketwatch/plugins/debug/debug.py +++ b/rocketwatch/plugins/debug/debug.py @@ -1,57 +1,31 @@ -import io -import json import logging import random import time +from datetime import UTC +from typing import cast -import humanize -import requests -from colorama import Fore, Style -from discord import File, Object, Interaction -from discord.app_commands import Choice, command, guilds, describe +from discord import Interaction +from discord.abc import Messageable +from discord.app_commands import command, guilds from discord.ext.commands import Cog, is_owner -from motor.motor_asyncio import AsyncIOMotorClient +from eth_typing import HexStr from rocketwatch import RocketWatch -from utils import solidity -from utils.cfg import cfg -from utils.embeds import Embed, el_explorer_url -from utils.block_time import ts_to_block, block_to_ts -from utils.readable import prettify_json_string +from utils.config import cfg +from utils.embeds import Embed +from utils.file import TextFile from utils.rocketpool import rp from utils.shared_w3 import w3 -from utils.visibility import is_hidden, is_hidden_weak, is_hidden_role_controlled -log = logging.getLogger("debug") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.debug") class Debug(Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch - self.contract_names = [] - self.function_names = [] - - # --------- LISTENERS --------- # - - @Cog.listener() - async def on_ready(self): - if self.function_names: - return - - for contract in rp.addresses.copy(): - try: - for function in rp.get_contract_by_name(contract).functions: - self.function_names.append(f"{contract}.{function}") - self.contract_names.append(contract) - except Exception: - log.exception(f"Could not get function list for {contract}") - - # --------- PRIVATE OWNER COMMANDS --------- # @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() async def raise_exception(self, interaction: Interaction): """ @@ -61,46 +35,53 @@ async def raise_exception(self, interaction: Interaction): raise Exception("this should never happen wtf is your filesystem") @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() - async def get_members_of_role(self, interaction: Interaction, guild_id: str, role_id: str): + async def get_members_of_role( + self, interaction: Interaction, guild_id: str, role_id: str + ): """Get members of a role""" await interaction.response.defer(ephemeral=True) try: - guild = self.bot.get_guild(int(guild_id)) - log.debug(guild) - role = guild.get_role(int(role_id)) - log.debug(role) + guild = await self.bot.get_or_fetch_guild(int(guild_id)) + role = await self.bot.get_or_fetch_role(int(guild_id), int(role_id)) # print name + identifier and id of each member - members = [f"{member.name}#{member.discriminator}, ({member.id})" for member in role.members] + members = [ + f"{member.name}#{member.discriminator}, ({member.id})" + for member in role.members + ] # generate a file with a header that mentions what role and guild the members are from - content = f"Members of {role.name} ({role.id}) in {guild.name} ({guild.id})\n\n" + "\n".join(members) - file = File(io.StringIO(content), "members.txt") + content = ( + f"Members of {role.name} ({role.id}) in {guild.name} ({guild.id})\n\n" + + "\n".join(members) + ) + file = TextFile(content, "members.txt") await interaction.followup.send(file=file) except Exception as err: - await interaction.followup.send(content=f"```{repr(err)}```") + await interaction.followup.send(content=f"```{err!r}```") # list all roles of a guild with name and id @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() async def get_roles(self, interaction: Interaction, guild_id: str): """Get roles of a guild""" await interaction.response.defer(ephemeral=True) try: guild = self.bot.get_guild(int(guild_id)) + assert guild is not None log.debug(guild) # print name + identifier and id of each member roles = [f"{role.name}, ({role.id})" for role in guild.roles] # generate a file with a header that mentions what role and guild the members are from content = f"Roles of {guild.name} ({guild.id})\n\n" + "\n".join(roles) - file = File(io.StringIO(content), filename="roles.txt") + file = TextFile(content, "roles.txt") await interaction.followup.send(file=file) except Exception as err: - await interaction.followup.send(content=f"```{repr(err)}```") + await interaction.followup.send(content=f"```{err!r}```") @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() async def delete_msg(self, interaction: Interaction, message_url: str): """ @@ -109,320 +90,207 @@ async def delete_msg(self, interaction: Interaction, message_url: str): await interaction.response.defer(ephemeral=True) channel_id, message_id = message_url.split("/")[-2:] channel = await self.bot.get_or_fetch_channel(int(channel_id)) + assert isinstance(channel, Messageable) msg = await channel.fetch_message(int(message_id)) await msg.delete() await interaction.followup.send(content="Done") @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() - async def decode_tnx(self, interaction: Interaction, tnx_hash: str, contract_name: str = None): - """ - Decode transaction calldata - """ + async def edit_embed( + self, interaction: Interaction, message_url: str, new_description: str + ): await interaction.response.defer(ephemeral=True) - tnx = w3.eth.get_transaction(tnx_hash) - if contract_name: - contract = rp.get_contract_by_name(contract_name) - else: - contract = rp.get_contract_by_address(tnx.to) - data = contract.decode_function_input(tnx.input) - await interaction.followup.send(content=f"```Input:\n{data}```") + channel_id, message_id = message_url.split("/")[-2:] + channel = await self.bot.get_or_fetch_channel(int(channel_id)) + assert isinstance(channel, Messageable) + msg = await channel.fetch_message(int(message_id)) + embed = msg.embeds[0] + embed.description = new_description + await msg.edit(embed=embed) + await interaction.followup.send(content="Done") @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() async def debug_transaction(self, interaction: Interaction, tnx_hash: str): """ Try to return the revert reason of a transaction. """ await interaction.response.defer(ephemeral=True) - transaction_receipt = w3.eth.getTransaction(tnx_hash) - if revert_reason := rp.get_revert_reason(transaction_receipt): - await interaction.followup.send(content=f"```Revert reason: {revert_reason}```") + transaction_receipt = await w3.eth.get_transaction(HexStr(tnx_hash)) + if revert_reason := await rp.get_revert_reason(transaction_receipt): + await interaction.followup.send( + content=f"```Revert reason: {revert_reason}```" + ) else: await interaction.followup.send(content="```No revert reason Available```") @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() async def purge_minipools(self, interaction: Interaction, confirm: bool = False): """ - Purge minipool collection, so it can be resynced from scratch in the next update. - """ - await interaction.response.defer(ephemeral=True) - if not confirm: - await interaction.followup.send("Not running. Set `confirm` to `true` to run.") - return - await self.db.minipools.drop() - await interaction.followup.send(content="Done") - - @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) - @is_owner() - async def purge_minipools_new(self, interaction: Interaction, confirm: bool = False): - """ - Purge minipools_new collection, so it can be resynced from scratch in the next update. + Purge minipools collection, so it can be resynced from scratch in the next update. """ await interaction.response.defer(ephemeral=True) if not confirm: - await interaction.followup.send("Not running. Set `confirm` to `true` to run.") + await interaction.followup.send( + "Not running. Set `confirm` to `true` to run." + ) return - await self.db.minipools_new.drop() + await self.bot.db.minipools.drop() await interaction.followup.send(content="Done") @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() async def sync_commands(self, interaction: Interaction): """ - Full sync of the commands tree + Full sync of the command tree """ await interaction.response.defer(ephemeral=True) await self.bot.sync_commands() await interaction.followup.send(content="Done") @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() - async def talk(self, interaction: Interaction, channel: str, message: str): + async def talk(self, interaction: Interaction, channel_id: str, message: str): """ Send a message to a channel. """ await interaction.response.defer(ephemeral=True) - channel = await self.bot.get_or_fetch_channel(int(channel)) + channel = await self.bot.get_or_fetch_channel(int(channel_id)) + assert isinstance(channel, Messageable) await channel.send(message) await interaction.followup.send(content="Done") @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() - async def announce(self, interaction: Interaction, channel: str, message: str): + async def announce(self, interaction: Interaction, channel_id: str, message: str): """ Send a message to a channel. """ await interaction.response.defer(ephemeral=True) - channel = await self.bot.get_or_fetch_channel(int(channel)) + channel = await self.bot.get_or_fetch_channel(int(channel_id)) + assert isinstance(channel, Messageable) e = Embed(title="Announcement", description=message) - e.add_field(name="Timestamp", value=f" ()") + e.add_field( + name="Timestamp", + value=f" ()", + ) await channel.send(embed=e) await interaction.followup.send(content="Done") @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() - async def restore_support_template(self, interaction: Interaction, template_name: str, message_url: str): + async def restore_support_template( + self, interaction: Interaction, template_name: str, message_url: str + ): await interaction.response.defer(ephemeral=True) channel_id, message_id = message_url.split("/")[-2:] channel = await self.bot.get_or_fetch_channel(int(channel_id)) - - msg = await channel.fetch_message(int(message_id)) + assert isinstance(channel, Messageable) + + msg = await channel.fetch_message(int(message_id)) template_embed = msg.embeds[0] template_title = template_embed.title + assert template_embed.description is not None template_description = "\n".join(template_embed.description.splitlines()[:-2]) - + import re - from datetime import datetime, timezone - + from datetime import datetime + edit_line = template_embed.description.splitlines()[-1] - match = re.search(r"Last Edited by <@(?P[0-9]+)> [0-9]+):R>", edit_line) + match = re.search( + r"Last Edited by <@(?P[0-9]+)> [0-9]+):R>", edit_line + ) + if match is None: + await interaction.followup.send( + "Failed to restore support template. The provided message doesn't match the expected format." + ) + return + user_id = int(match.group("user")) ts = int(match.group("ts")) - + user = await self.bot.get_or_fetch_user(user_id) - - await self.db.support_bot_dumps.insert_one( + + await self.bot.db.support_bot_dumps.insert_one( { - "ts" : datetime.fromtimestamp(ts, tz=timezone.utc), + "ts": datetime.fromtimestamp(ts, tz=UTC), "template": template_name, - "prev" : None, - "new" : { - "title" : template_title, - "description": template_description - }, - "author" : { - "id" : user.id, - "name": user.name - } + "prev": None, + "new": {"title": template_title, "description": template_description}, + "author": {"id": user.id, "name": user.name}, } ) - await self.db.support_bot.insert_one( - {"_id": template_name, "title": template_title, "description": template_description} + await self.bot.db.support_bot.insert_one( + { + "_id": template_name, + "title": template_title, + "description": template_description, + } ) - + await interaction.followup.send(content="Done") @command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @guilds(cfg.discord.owner.server_id) @is_owner() async def restore_missed_events(self, interaction: Interaction, tx_hash: str): import pickle from datetime import datetime + from plugins.events.events import Events await interaction.response.defer(ephemeral=True) - events_plugin: Events = self.bot.cogs["Events"] + events_plugin = cast(Events, self.bot.cogs["Events"]) filtered_events = [] - for event_log in w3.eth.get_transaction_receipt(tx_hash).logs: - if ("topics" in event_log) and (event_log["topics"][0].hex() in events_plugin.topic_map): + for event_log in (await w3.eth.get_transaction_receipt(HexStr(tx_hash)))[ + "logs" + ]: + if ("topics" in event_log) and ( + event_log["topics"][0].hex() in events_plugin.topic_map + ): filtered_events.append(event_log) - channels = cfg["discord.channels"] - events, _ = events_plugin.process_events(filtered_events) + channels = cfg.discord.channels + events, _ = await events_plugin.process_events(filtered_events) for event in events: - channel_candidates = [value for key, value in channels.items() if event.event_name.startswith(key)] - channel_id = channel_candidates[0] if channel_candidates else channels["default"] - await self.db.event_queue.insert_one({ - "_id": event.unique_id, - "embed": pickle.dumps(event.embed), - "topic": event.topic, - "event_name": event.event_name, - "block_number": event.block_number, - "score": event.get_score(), - "time_seen": datetime.now(), - "attachment": pickle.dumps(event.attachment) if event.attachment else None, - "channel_id": channel_id, - "message_id": None - }) - await interaction.followup.send(embed=event.embed) - await interaction.followup.send(content="Done") - - # --------- PUBLIC COMMANDS --------- # - - @command() - async def color_test(self, interaction: Interaction): - """ - Simple test to check ansi color support - """ - await interaction.response.defer(ephemeral=is_hidden(interaction)) - payload = "```ansi" - for fg_name, fg in Fore.__dict__.items(): - if fg_name.endswith("_EX"): - continue - payload += f"\n{fg}Hello World" - payload += f"{Style.RESET_ALL}```" - await interaction.followup.reply(content=payload) - - @command() - async def asian_restaurant_name(self, interaction: Interaction): - """ - Randomly generated Asian restaurant names - """ - await interaction.response.defer(ephemeral=is_hidden_weak(interaction)) - a = requests.get("https://www.dotomator.com/api/random_name.json?type=asian").json()["name"] - await interaction.followup.reply(a) - - @command() - async def get_block_by_timestamp(self, interaction: Interaction, timestamp: int): - """ - Get a block using its timestamp. Useful for contracts that track block time instead of block number. - """ - await interaction.response.defer(ephemeral=is_hidden(interaction)) - - block = ts_to_block(timestamp) - found_ts = block_to_ts(block) - - if found_ts == timestamp: - text = ( - f"Found perfect match for timestamp {timestamp}:\n" - f"Block: {block}" + channel_candidates = [ + value + for key, value in channels.items() + if event.event_name.startswith(key) + ] + channel_id = ( + channel_candidates[0] if channel_candidates else channels["default"] ) - else: - text = ( - f"Found close match for timestamp {timestamp}:\n" - f"Timestamp: {found_ts}\n" - f"Block: {block}" + await self.bot.db.event_queue.insert_one( + { + "_id": event.unique_id, + "embed": pickle.dumps(event.embed), + "topic": event.topic, + "event_name": event.event_name, + "block_number": event.block_number, + "score": event.get_score(), + "time_seen": datetime.now(), + "image": pickle.dumps(event.image) if event.image else None, + "thumbnail": pickle.dumps(event.thumbnail) + if event.thumbnail + else None, + "channel_id": channel_id, + "message_id": None, + } ) - - await interaction.followup.send(content=f"```{text}```") - - @command() - async def get_abi_of_contract(self, interaction: Interaction, contract: str): - """Retrieve the latest ABI for a contract""" - await interaction.response.defer(ephemeral=is_hidden_role_controlled(interaction)) - try: - abi = prettify_json_string(rp.uncached_get_abi_by_name(contract)) - file = File(io.StringIO(abi), f"{contract}.{cfg['rocketpool.chain'].lower()}.abi.json") - await interaction.followup.send(file=file) - except Exception as err: - await interaction.followup.send(content=f"```Exception: {repr(err)}```") - - @command() - async def get_address_of_contract(self, interaction: Interaction, contract: str): - """Retrieve the latest address for a contract""" - await interaction.response.defer(ephemeral=is_hidden_role_controlled(interaction)) - try: - address = cfg["rocketpool.manual_addresses"].get(contract) - if not address: - address = rp.uncached_get_address_by_name(contract) - await interaction.followup.send(content=el_explorer_url(address)) - except Exception as err: - await interaction.followup.send(content=f"Exception: ```{repr(err)}```") - if "No address found for" in repr(err): - # private response as a tip - m = "It may be that you are requesting the address of a contract that does not get deployed (`rocketBase` for example), " \ - " is deployed multiple times (i.e node operator related contracts, like `rocketNodeDistributor`)," \ - " or is not yet deployed on the current chain.\n... Or you simply messed up the name :P" - await interaction.followup.send(content=m) - - @command() - @describe( - json_args="json formatted arguments. example: `[1, \"World\"]`", - block="call against block state" - ) - async def call( - self, - interaction: Interaction, - function: str, - json_args: str = "[]", - block: str = "latest", - address: str = None, - raw_output: bool = False - ): - """Call Function of Contract""" - await interaction.response.defer(ephemeral=is_hidden_role_controlled(interaction)) - # convert block to int if number - if block.isnumeric(): - block = int(block) - try: - args = json.loads(json_args) - if not isinstance(args, list): - args = [args] - v = rp.call(function, *args, block=block, address=w3.toChecksumAddress(address) if address else None) - except Exception as err: - await interaction.followup.send(content=f"Exception: ```{repr(err)}```") - return - try: - g = rp.estimate_gas_for_call(function, *args, block=block) - except Exception as err: - g = "N/A" - if isinstance(err, ValueError) and err.args and "code" in err.args and err.args[0]["code"] == -32000: - g += f" ({err.args[0]['message']})" - - if isinstance(v, int) and abs(v) >= 10 ** 12 and not raw_output: - v = solidity.to_float(v) - g = humanize.intcomma(g) - text = f"`block: {block}`\n`gas estimate: {g}`\n`{function}({', '.join([repr(a) for a in args])}): " - if len(text + str(v)) > 2000: - text += "too long, attached as file`" - await interaction.followup.send(text, file=File(io.StringIO(str(v)), "exception.txt")) - else: - text += f"{str(v)}`" - await interaction.followup.send(content=text) - - # --------- OTHERS --------- # - - @get_address_of_contract.autocomplete("contract") - @get_abi_of_contract.autocomplete("contract") - @decode_tnx.autocomplete("contract_name") - async def match_contract_names(self, interaction: Interaction, current: str) -> list[Choice[str]]: - return [Choice(name=name, value=name) for name in self.contract_names if current.lower() in name.lower()][:25] - - @call.autocomplete("function") - async def match_function_name(self, interaction: Interaction, current: str) -> list[Choice[str]]: - return [Choice(name=name, value=name) for name in self.function_names if current.lower() in name.lower()][:25] + await interaction.followup.send(embed=event.embed) + await interaction.followup.send(content="Done") async def setup(bot): diff --git a/rocketwatch/plugins/defi/defi.py b/rocketwatch/plugins/defi/defi.py deleted file mode 100644 index 497f2a2e..00000000 --- a/rocketwatch/plugins/defi/defi.py +++ /dev/null @@ -1,118 +0,0 @@ -import logging - -from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command - -from rocketwatch import RocketWatch -from utils import solidity -from utils.cfg import cfg -from utils.embeds import Embed, el_explorer_url -from utils.rocketpool import rp -from utils.shared_w3 import w3 -from utils.visibility import is_hidden_weak - -log = logging.getLogger("defi") -log.setLevel(cfg["log_level"]) - - -class DeFi(commands.Cog): - def __init__(self, bot: RocketWatch): - self.bot = bot - - @hybrid_command() - async def curve(self, ctx: Context): - """ - Show stats of the curve pool - """ - await ctx.defer(ephemeral=is_hidden_weak(ctx)) - e = Embed() - e.title = "Curve Pool" - reth_r, wsteth_r = rp.call("curvePool.get_balances") - # token amounts - reth = solidity.to_float(reth_r) - wsteth = solidity.to_float(wsteth_r) - # token values - reth_v = solidity.to_float(rp.call("rocketTokenRETH.getEthValue", reth_r)) - wsteth_v = solidity.to_float(rp.call("wstETHToken.getStETHByWstETH", wsteth_r)) - # token shares - reth_s = reth / (reth + wsteth) - wsteth_s = wsteth / (reth + wsteth) - e.add_field( - name="rETH Locked", - value=f"`{reth:,.2f} rETH ({reth_s:.0%})`", - ) - e.add_field( - name="wstETH Locked", - value=f"`{wsteth:,.2f} wstETH ({wsteth_s:.0%})`", - ) - total_locked = reth_v + wsteth_v - total_locked_usd = total_locked * rp.get_eth_usdc_price() - e.add_field( - name="Total Value Locked", - value=f"`{total_locked:,.2f} ETH ({total_locked_usd:,.2f} USDC)", - inline=False, - ) - # rETH => wstETH premium - eth_to_wsteth = rp.call("curvePool.get_dy", 0, 1, rp.call("rocketTokenRETH.getRethValue", w3.toWei(1, "ether"))) - e.add_field( - name="Current rETH => wstETH Exchange (Assuming true-lsd value)", - value=f"`1 ETH worth of rETH will get you " - f"{solidity.to_float(rp.call('wstETHToken.getStETHByWstETH',eth_to_wsteth)):,.4f} ETH " - f"worth of wstETH`", - inline=False, - ) - # wstETH => rETH premium - eth_to_reth = rp.call("curvePool.get_dy", 1, 0, rp.call("wstETHToken.getWstETHByStETH", w3.toWei(1, "ether"))) - e.add_field( - name="Current wstETH => rETH Exchange (Assuming true-lsd value)", - value=f"`1 ETH worth of wstETH will get you " - f"{solidity.to_float(rp.call('rocketTokenRETH.getEthValue',eth_to_reth)):,.4f} ETH" - f" worth of rETH`", - inline=False, - ) - token_name = rp.call("curvePool.symbol") - link = el_explorer_url(rp.get_address_by_name("curvePool"), token_name) - e.add_field( - name="Contract Address", - value=link, - ) - await ctx.send(embed=e) - - @hybrid_command() - async def yearn(self, ctx: Context): - """ - Show stats of the yearn vault - """ - await ctx.defer(ephemeral=is_hidden_weak(ctx)) - e = Embed() - e.title = "Yearn Pool" - deposit_limit = solidity.to_float(rp.call("yearnPool.depositLimit")) - deposited = solidity.to_float(rp.call("yearnPool.totalAssets")) - asset_name = rp.call("curvePool.symbol") - e.add_field( - name="Deposit Limit Status", - value=f"`{deposited:,.2f}/{deposit_limit:,.2f} {asset_name}`", - ) - reth_r, wsteth_r = rp.call("curvePool.get_balances") - # token values - reth_v = solidity.to_float(rp.call("rocketTokenRETH.getEthValue", reth_r)) - wsteth_v = solidity.to_float(rp.call("wstETHToken.getStETHByWstETH", wsteth_r)) - yearn_locked = (reth_v + wsteth_v) * (rp.call("yearnPool.totalAssets") / rp.call("curvePool.totalSupply")) - yearn_locked_usd = yearn_locked * rp.get_eth_usdc_price() - e.add_field( - name="Total Value Locked", - value=f"`{yearn_locked:,.2f} ETH ({yearn_locked_usd:,.2f} USDC)`", - inline=False - ) - token_name = rp.call("yearnPool.symbol") - link = el_explorer_url(rp.get_address_by_name("yearnPool"), token_name) - e.add_field( - name="Contract Address", - value=link, - ) - await ctx.send(embed=e) - - -async def setup(bot): - await bot.add_cog(DeFi(bot)) diff --git a/rocketwatch/plugins/delegate_contracts/delegate_contracts.py b/rocketwatch/plugins/delegate_contracts/delegate_contracts.py new file mode 100644 index 00000000..3feff8eb --- /dev/null +++ b/rocketwatch/plugins/delegate_contracts/delegate_contracts.py @@ -0,0 +1,108 @@ +import logging + +from discord import Interaction +from discord.app_commands import command +from discord.ext import commands +from pymongo.asynchronous.collection import AsyncCollection + +from rocketwatch import RocketWatch +from utils.embeds import Embed, el_explorer_url +from utils.readable import s_hex +from utils.rocketpool import rp +from utils.shared_w3 import w3 + +log = logging.getLogger("rocketwatch.delegate_contracts") + + +class DelegateContracts(commands.Cog): + def __init__(self, bot: RocketWatch): + self.bot = bot + + async def _delegate_stats( + self, + collection: AsyncCollection, + match_filter: dict, + delegate_field: str, + use_latest_field: str, + latest_contract: str, + title: str, + ) -> Embed: + distribution_stats = await ( + await collection.aggregate( + [ + {"$match": match_filter}, + {"$group": {"_id": f"${delegate_field}", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ] + ) + ).to_list() + + use_latest_counts = {True: 0, False: 0} + for d in await ( + await collection.aggregate( + [ + {"$match": match_filter}, + {"$group": {"_id": f"${use_latest_field}", "count": {"$sum": 1}}}, + ] + ) + ).to_list(): + use_latest_counts[bool(d["_id"])] = d["count"] + + e = Embed() + e.title = title + s = "\u00a0" * 4 + desc = "**Effective Delegate Distribution:**\n" + c_sum = sum(d["count"] for d in distribution_stats) + # refresh cached address + await rp.uncached_get_address_by_name(latest_contract) + latest_addr = await rp.get_address_by_name(latest_contract) + for d in distribution_stats: + a = w3.to_checksum_address(d["_id"]) + name = s_hex(a) + if a == latest_addr: + name += " (Latest)" + desc += f"{s}{await el_explorer_url(a, name)}: {d['count']:,} ({d['count'] / c_sum * 100:.2f}%)\n" + desc += "\n" + desc += "**Use Latest Delegate:**\n" + c_sum = sum(use_latest_counts.values()) + for value, label in [(True, "Yes"), (False, "No")]: + count = use_latest_counts[value] + desc += f"{s}**{label}**: {count:,} ({count / c_sum * 100:.2f}%)\n" + e.description = desc + return e + + @command() + async def minipool_delegates(self, interaction: Interaction): + """Show stats for minipool delegate contract adoption""" + await interaction.response.defer() + e = await self._delegate_stats( + collection=self.bot.db.minipools, + match_filter={ + "beacon.status": { + "$in": ["pending_initialized", "pending_queued", "active_ongoing"] + } + }, + delegate_field="effective_delegate", + use_latest_field="use_latest_delegate", + latest_contract="rocketMinipoolDelegate", + title="Minipool Delegate Stats", + ) + await interaction.followup.send(embed=e) + + @command() + async def megapool_delegates(self, interaction: Interaction): + """Show stats for megapool delegate contract adoption""" + await interaction.response.defer() + e = await self._delegate_stats( + collection=self.bot.db.node_operators, + match_filter={"megapool.active_validator_count": {"$gt": 0}}, + delegate_field="megapool.effective_delegate", + use_latest_field="megapool.use_latest_delegate", + latest_contract="rocketMegapoolDelegate", + title="Megapool Delegate Stats", + ) + await interaction.followup.send(embed=e) + + +async def setup(self): + await self.add_cog(DelegateContracts(self)) diff --git a/rocketwatch/plugins/deposit_pool/deposit_pool.py b/rocketwatch/plugins/deposit_pool/deposit_pool.py index 00099342..a4e5557b 100644 --- a/rocketwatch/plugins/deposit_pool/deposit_pool.py +++ b/rocketwatch/plugins/deposit_pool/deposit_pool.py @@ -1,44 +1,45 @@ import logging -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command -from motor.motor_asyncio import AsyncIOMotorClient +from discord import Interaction +from discord.app_commands import command -from rocketwatch import RocketWatch from plugins.queue.queue import Queue -from utils.status import StatusPlugin +from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg +from utils.config import cfg from utils.embeds import Embed from utils.rocketpool import rp -from utils.visibility import is_hidden_weak +from utils.status import StatusPlugin +from utils.visibility import is_hidden -log = logging.getLogger("deposit_pool") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.deposit_pool") class DepositPool(StatusPlugin): def __init__(self, bot: RocketWatch): super().__init__(bot) - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch @staticmethod - def get_deposit_pool_stats() -> Embed: - multicall: dict[str, int] = { - res.function_name: res.results[0] for res in rp.multicall.aggregate([ - rp.get_contract_by_name("rocketDepositPool").functions.getBalance(), - rp.get_contract_by_name("rocketDAOProtocolSettingsDeposit").functions.getMaximumDepositPoolSize(), - rp.get_contract_by_name("rocketDepositPool").functions.getMaximumDepositAmount(), - ]).results - } - - dp_balance = solidity.to_float(multicall["getBalance"]) - deposit_cap = solidity.to_int(multicall["getMaximumDepositPoolSize"]) + async def get_deposit_pool_stats() -> Embed: + dp_contract = await rp.get_contract_by_name("rocketDepositPool") + dp_settings_contract = await rp.get_contract_by_name( + "rocketDAOProtocolSettingsDeposit" + ) + balance_raw, max_size_raw, max_amount_raw = await rp.multicall( + [ + dp_contract.functions.getBalance(), + dp_settings_contract.functions.getMaximumDepositPoolSize(), + dp_contract.functions.getMaximumDepositAmount(), + ] + ) + + dp_balance = solidity.to_float(balance_raw) + deposit_cap = solidity.to_int(max_size_raw) + free_capacity = solidity.to_float(max_amount_raw) if deposit_cap - dp_balance < 0.01: dp_status = "Capacity reached!" else: - free_capacity = solidity.to_float(multicall["getMaximumDepositAmount"]) dp_status = f"Enough space for **{free_capacity:,.2f} ETH**." embed = Embed(title="Deposit Pool Stats") @@ -46,41 +47,72 @@ def get_deposit_pool_stats() -> Embed: embed.add_field(name="Maximum Size", value=f"{deposit_cap:,} ETH") embed.add_field(name="Status", value=dp_status, inline=False) - display_limit = 3 - queue_length, queue_content = Queue.get_minipool_queue(display_limit) - if queue_length > 0: - embed.description = f"**Minipool Queue ({queue_length})**\n" - embed.description += queue_content - if queue_length > display_limit: - embed.description += f"{display_limit + 1}. `...`\n" - queue_capacity = max(queue_length * 31 - dp_balance, 0.0) - embed.description += f"Need **{queue_capacity:,.2f} ETH** to dequeue all minipools." + display_limit = 2 + exp_queue_length, exp_queue_content = await Queue.get_express_queue( + display_limit + ) + std_queue_length, std_queue_content = await Queue.get_standard_queue( + display_limit + ) + total_queue_length = exp_queue_length + std_queue_length + if (total_queue_length) > 0: + embed.description = "" + if exp_queue_length > 0: + embed.description += f"🐇 **Express Queue ({exp_queue_length})**\n" + embed.description += exp_queue_content + if exp_queue_length > display_limit: + embed.description += f"{display_limit + 1}. `...`\n" + if std_queue_length > 0: + embed.description += f"🐢 **Standard Queue ({std_queue_length})**\n" + embed.description += std_queue_content + if std_queue_length > display_limit: + embed.description += f"{display_limit + 1}. `...`\n" + + queue_capacity = max(free_capacity - deposit_cap, 0.0) + possible_assignments = min(int(dp_balance // 32), total_queue_length) + + embed.description += ( + f"Need **{queue_capacity:,.2f} ETH** to dequeue all validators." + ) + if possible_assignments > 0: + embed.description += f"\nSufficient balance for **{possible_assignments} deposit assignment{'s' if possible_assignments != 1 else ''}**!" else: lines = [] - if (num_leb8 := int(dp_balance // 24)) > 0: - lines.append(f"**`{num_leb8:>4}`** 8 ETH minipools (24 ETH from DP)") + if (num_eb4 := int(dp_balance // 28)) > 0: + lines.append(f"**`{num_eb4:>4}`** 4 ETH validators (28 ETH from DP)") if (num_credit := int(dp_balance // 32)) > 0: - lines.append(f"**`{num_credit:>4}`** credit minipools (32 ETH from DP)") + lines.append( + f"**`{num_credit:>4}`** credit validators (32 ETH from DP)" + ) if lines: embed.add_field(name="Enough For", value="\n".join(lines), inline=False) return embed - + @staticmethod - def get_contract_collateral_stats() -> Embed: - multicall: dict[str, int] = { - res.function_name: res.results[0] for res in rp.multicall.aggregate([ - rp.get_contract_by_name("rocketTokenRETH").functions.getExchangeRate(), - rp.get_contract_by_name("rocketTokenRETH").functions.totalSupply(), - rp.get_contract_by_name("rocketTokenRETH").functions.getCollateralRate(), - rp.get_contract_by_name("rocketDAOProtocolSettingsNetwork").functions.getTargetRethCollateralRate(), - ]).results - } - - total_eth_in_reth: float = multicall["totalSupply"] * multicall["getExchangeRate"] / 10**36 - collateral_rate: float = solidity.to_float(multicall["getCollateralRate"]) - collateral_rate_target: float = solidity.to_float(multicall["getTargetRethCollateralRate"]) + async def get_contract_collateral_stats() -> Embed: + reth_contract = await rp.get_contract_by_name("rocketTokenRETH") + network_setting_contract = await rp.get_contract_by_name( + "rocketDAOProtocolSettingsNetwork" + ) + ( + exchange_rate, + total_supply, + collateral_rate_raw, + target_rate_raw, + ) = await rp.multicall( + [ + reth_contract.functions.getExchangeRate(), + reth_contract.functions.totalSupply(), + reth_contract.functions.getCollateralRate(), + network_setting_contract.functions.getTargetRethCollateralRate(), + ] + ) + + total_eth_in_reth: float = total_supply * exchange_rate / 10**36 + collateral_rate: float = solidity.to_float(collateral_rate_raw) + collateral_rate_target: float = solidity.to_float(target_rate_raw) collateral_eth: float = total_eth_in_reth * collateral_rate collateral_target_eth: float = total_eth_in_reth * collateral_rate_target @@ -93,29 +125,30 @@ def get_contract_collateral_stats() -> Embed: else: collateral_target_perc = collateral_eth / collateral_target_eth description = ( - f"**{collateral_eth:,.2f} ETH** of liquidity in the rETH contract.\n" + f"**{collateral_eth:,.2f} ETH** of liquidity in the rETH contract\n" f"**{collateral_target_perc:.2%}** of the {collateral_target_eth:,.0f} ETH target" - f" ({collateral_rate:.2%}/{collateral_rate_target:.0%})." ) return Embed(title="rETH Extra Collateral", description=description) - - @hybrid_command() - async def deposit_pool(self, ctx: Context) -> None: + + @command() + async def deposit_pool(self, interaction: Interaction) -> None: """Show the current deposit pool status""" - await ctx.defer(ephemeral=is_hidden_weak(ctx)) - await ctx.send(embed=self.get_deposit_pool_stats()) + await interaction.response.defer(ephemeral=is_hidden(interaction)) + await interaction.followup.send(embed=await self.get_deposit_pool_stats()) - @hybrid_command() - async def reth_extra_collateral(self, ctx: Context) -> None: + @command() + async def reth_extra_collateral(self, interaction: Interaction) -> None: """Show the amount of tokens held in the rETH contract for exit liquidity""" - await ctx.defer(ephemeral=is_hidden_weak(ctx)) - await ctx.send(embed=self.get_contract_collateral_stats()) - + await interaction.response.defer(ephemeral=is_hidden(interaction)) + await interaction.followup.send( + embed=await self.get_contract_collateral_stats() + ) + async def get_status(self) -> Embed: embed = Embed(title=":rocket: Live Protocol Status") - dp_embed = self.get_deposit_pool_stats() + dp_embed = await self.get_deposit_pool_stats() embed.description = dp_embed.description dp_fields = {field.name: field for field in dp_embed.fields} @@ -128,14 +161,18 @@ async def get_status(self) -> Embed: if field := dp_fields.get("Status"): embed.add_field(name="Deposits", value=field.value, inline=False) - collateral_embed = self.get_contract_collateral_stats() - embed.add_field(name="Withdrawals", value=collateral_embed.description, inline=False) - - if cfg["rocketpool.chain"] != "mainnet": + collateral_embed = await self.get_contract_collateral_stats() + embed.add_field( + name="Withdrawals", value=collateral_embed.description, inline=False + ) + + if cfg.rocketpool.chain != "mainnet": return embed - reth_price = rp.get_reth_eth_price() - protocol_rate = solidity.to_float(rp.call("rocketTokenRETH.getExchangeRate")) + reth_price = await rp.get_reth_eth_price() + protocol_rate = solidity.to_float( + await rp.call("rocketTokenRETH.getExchangeRate") + ) relative_rate_diff = (reth_price / protocol_rate) - 1 expected_rate_diff = 0.0005 @@ -146,60 +183,13 @@ async def get_status(self) -> Embed: else: rate_status = f"at a **{-relative_rate_diff:.2%} discount**!" - embed.add_field(name="Secondary Market", value=f"rETH is trading {rate_status}", inline=False) + embed.add_field( + name="Secondary Market", + value=f"rETH is trading {rate_status}", + inline=False, + ) return embed - @hybrid_command() - async def atlas_queue(self, ctx): - await ctx.defer(ephemeral=is_hidden_weak(ctx)) - - e = Embed() - e.title = "Atlas Queue Stats" - - data = await self.db.minipools_new.aggregate([ - { - '$match': { - 'status' : 'initialised', - 'deposit_amount': { - '$gt': 1 - } - } - }, { - '$group': { - '_id' : 'total', - 'value' : { - '$sum': { - '$subtract': [ - '$deposit_amount', 1 - ] - } - }, - 'count' : { - '$sum': 1 - }, - 'count_16': { - '$sum': { - '$floor': { - '$divide': [ - '$node_deposit_balance', 16 - ] - } - } - } - } - } - ]).to_list(None) - - total = int(data[0]['value']) - count = data[0]['count'] - count_16 = int(data[0]['count_16']) - count_8 = count - count_16 - - e.description = f"Amount deposited into deposit pool by queued minipools: **{total} ETH**\n" \ - f"Non-credit minipools in the queue: **{count}** (16 ETH: **{count_16}**, 8 ETH: **{count_8}**)\n" \ - - await ctx.send(embed=e) - async def setup(bot): await bot.add_cog(DepositPool(bot)) diff --git a/rocketwatch/plugins/detect_scam/detect_scam.py b/rocketwatch/plugins/detect_scam/detect_scam.py deleted file mode 100644 index 48adbf32..00000000 --- a/rocketwatch/plugins/detect_scam/detect_scam.py +++ /dev/null @@ -1,606 +0,0 @@ -import io -import asyncio -import logging -import contextlib -import regex as re - -from urllib import parse -from typing import Optional -from datetime import datetime, timezone, timedelta - -from cachetools import TTLCache -from discord import ( - ui, - AppCommandType, - ButtonStyle, - errors, - File, - Color, - User, - Member, - Message, - Reaction, - Guild, - Thread, - DeletedReferencedMessage, - Interaction, - RawMessageDeleteEvent, - RawBulkMessageDeleteEvent, - RawThreadUpdateEvent, - RawThreadDeleteEvent -) -from discord.ext.commands import Cog -from discord.app_commands import command, guilds, ContextMenu -from motor.motor_asyncio import AsyncIOMotorClient - -from rocketwatch import RocketWatch -from utils.cfg import cfg -from utils.embeds import Embed - -log = logging.getLogger("detect_scam") -log.setLevel(cfg["log_level"]) - - -class DetectScam(Cog): - class Color: - ALERT = Color.from_rgb(255, 0, 0) - WARN = Color.from_rgb(255, 165, 0) - OK = Color.from_rgb(0, 255, 0) - - @staticmethod - def is_reputable(user: Member) -> bool: - return any(( - user.id == cfg["discord.owner.user_id"], - {role.id for role in user.roles} & set(cfg["rocketpool.support.role_ids"]), - user.guild_permissions.moderate_members - )) - - class RemovalVoteView(ui.View): - THRESHOLD = 5 - - def __init__(self, plugin: 'DetectScam', reportable: Message | Thread): - super().__init__(timeout=None) - self.plugin = plugin - self.reportable = reportable - self.safu_votes = set() - - @ui.button(label="Mark Safu", style=ButtonStyle.blurple) - async def mark_safe(self, interaction: Interaction, button: ui.Button) -> None: - log.info(f"User {interaction.user.id} marked message {interaction.message.id} as safe") - - reportable_repr = type(self.reportable).__name__.lower() - if interaction.user.id in self.safu_votes: - log.debug(f"User {interaction.user.id} already voted on {reportable_repr}") - return await interaction.response.send_message(content="You already voted!", ephemeral=True) - - if interaction.user.is_timed_out(): - log.debug(f"Timed-out user {interaction.user.id} tried to vote on {self.reportable}") - return None - - if isinstance(self.reportable, Message): - reported_user = self.reportable.author - db_filter = {"type": "message", "message_id": self.reportable.id} - elif isinstance(self.reportable, Thread): - reported_user = self.reportable.owner - db_filter = {"type": "thread", "channel_id": self.reportable.id} - else: - log.warning(f"Unknown reportable type {type(self.reportable)}") - return None - - if interaction.user == reported_user: - log.debug(f"User {interaction.user.id} tried to mark their own {reportable_repr} as safe") - return await interaction.response.send_message( - content=f"You can't vote on your own {reportable_repr}!", - ephemeral=True - ) - - self.safu_votes.add(interaction.user.id) - - if DetectScam.is_reputable(interaction.user): - user_repr = interaction.user.mention - elif len(self.safu_votes) >= self.THRESHOLD: - user_repr = "the community" - else: - button.label = f"Mark Safu ({len(self.safu_votes)}/{self.THRESHOLD})" - return await interaction.response.edit_message(view=self) - - await interaction.message.delete() - async with self.plugin._update_lock: - report = await self.plugin.db.scam_reports.find_one(db_filter) - await self.plugin._update_report(report, f"This has been marked as safe by {user_repr}.") - await self.plugin.db.scam_reports.update_one(db_filter, {"$set": {"warning_id": None}}) - await interaction.response.send_message(content="Warning removed!", ephemeral=True) - - def __init__(self, bot: RocketWatch): - self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).get_database("rocketwatch") - - self._report_lock = asyncio.Lock() - self._update_lock = asyncio.Lock() - - self._message_react_cache = TTLCache(maxsize=1000, ttl=300) - self.markdown_link_pattern = re.compile(r"(?<=\[)([^/\] ]*).+?(?<=\(https?:\/\/)([^/\)]*)") - self.basic_url_pattern = re.compile(r"https?:\/\/([/\\@\-_0-9a-zA-Z]+\.)+[\\@\-_0-9a-zA-Z]+") - self.invite_pattern = re.compile(r"((discord(app)?\.com\/invite)|((dsc|discord)\.gg))(\\|\/)(?P[a-zA-Z0-9]+)") - - self.message_report_menu = ContextMenu( - name="Report Message", - callback=self.manual_message_report, - guild_ids=[cfg["rocketpool.support.server_id"]], - ) - self.bot.tree.add_command(self.message_report_menu) - self.user_report_menu = ContextMenu( - name="Report User", - callback=self.manual_user_report, - type=AppCommandType.user, - guild_ids=[cfg["rocketpool.support.server_id"]] - ) - self.bot.tree.add_command(self.user_report_menu) - - def cog_unload(self) -> None: - self.bot.tree.remove_command(self.message_report_menu.name, type=self.message_report_menu.type) - self.bot.tree.remove_command(self.user_report_menu.name, type=self.user_report_menu.type) - - @staticmethod - def _get_message_content(message: Message, *, preserve_formatting: bool = False) -> str: - text = "" - if message.content: - content = message.content if preserve_formatting else message.content.replace("\n", "") - text += content + "\n" - if message.embeds: - for embed in message.embeds: - text += f"---\n Embed: {embed.title}\n{embed.description}\n---\n" - return text if preserve_formatting else parse.unquote(text).lower() - - async def _generate_message_report(self, message: Message, reason: str) -> Optional[tuple[Embed, Embed, File]]: - try: - message = await message.channel.fetch_message(message.id) - if isinstance(message, DeletedReferencedMessage): - return None - except errors.NotFound: - return None - - async with self._report_lock: - if await self.db.scam_reports.find_one({"type": "message", "message_id": message.id}): - log.info(f"Found existing report for message {message.id} in database") - return None - - warning = Embed(title="🚨 Possible Scam Detected") - warning.color = self.Color.ALERT - warning.description = f"**Reason**: {reason}\n" - - report = warning.copy() - warning.set_footer(text="This message will be deleted once the suspicious message is removed.") - - report.description += ( - "\n" - f"User ID: `{message.author.id}` ({message.author.mention})\n" - f"Message ID: `{message.id}` ({message.jump_url})\n" - f"Channel ID: `{message.channel.id}` ({message.channel.jump_url})\n" - "\n" - "Original message has been attached as a file.\n" - "Please review and take appropriate action." - ) - - text = self._get_message_content(message, preserve_formatting=True) - with io.StringIO(text) as f: - contents = File(f, filename="original_message.txt") - - await self.db.scam_reports.insert_one({ - "type" : "message", - "guild_id" : message.guild.id, - "channel_id" : message.channel.id, - "message_id" : message.id, - "user_id" : message.author.id, - "reason" : reason, - "content" : text, - "warning_id" : None, - "report_id" : None, - "user_banned": False, - "removed" : False, - }) - return warning, report, contents - - async def _generate_thread_report(self, thread: Thread, reason: str) -> Optional[tuple[Embed, Embed]]: - try: - thread = await thread.guild.fetch_channel(thread.id) - except (errors.NotFound, errors.Forbidden): - return None - - async with self._report_lock: - if await self.db.scam_reports.find_one({"type": "thread", "channel_id": thread.id}): - log.info(f"Found existing report for thread {thread.id} in database") - return None - - warning = Embed(title="🚨 Possible Scam Detected") - warning.color = self.Color.ALERT - warning.description = f"**Reason**: {reason}\n" - - report = warning.copy() - warning.set_footer(text=( - "There is no ticket system for support on this server.\n" - "Ignore this thread and any invites or DMs you may receive." - )) - report.description += ( - "\n" - f"Thread Name: `{thread.name}`\n" - f"User ID: `{thread.owner}` ({thread.owner.mention})\n" - f"Thread ID: `{thread.id}` ({thread.jump_url})\n" - "\n" - "Please review and take appropriate action." - ) - await self.db.scam_reports.insert_one({ - "type" : "thread", - "guild_id" : thread.guild.id, - "channel_id" : thread.id, - "user_id" : thread.owner_id, - "reason" : reason, - "content" : thread.name, - "warning_id" : None, - "report_id" : None, - "user_banned": False, - "removed" : False, - }) - return warning, report - - async def report_message(self, message: Message, reason: str) -> None: - if not (components := await self._generate_message_report(message, reason)): - return None - - warning, report, contents = components - - try: - view = self.RemovalVoteView(self, message) - warning_msg = await message.reply(embed=warning, view=view, mention_author=False) - except errors.Forbidden: - warning_msg = None - log.warning(f"Failed to send warning message in reply to {message.id}") - - report_channel = await self.bot.get_or_fetch_channel(cfg["discord.channels.report_scams"]) - report_msg = await report_channel.send(embed=report, file=contents) - - await self.db.scam_reports.update_one( - {"message_id": message.id}, - {"$set": {"warning_id": warning_msg.id if warning_msg else None, "report_id": report_msg.id}} - ) - return None - - async def manual_message_report(self, interaction: Interaction, message: Message) -> None: - await interaction.response.defer(ephemeral=True) - - if message.author.bot: - return await interaction.followup.send(content="Bot messages can't be reported.") - - if message.author == interaction.user: - return await interaction.followup.send(content="Did you just report yourself?") - - reason = f"Manual report by {interaction.user.mention}" - if not (components := await self._generate_message_report(message, reason)): - return await interaction.followup.send( - content="Failed to report message. It may have already been reported or deleted." - ) - - warning, report, contents = components - - report_channel = await self.bot.get_or_fetch_channel(cfg["discord.channels.report_scams"]) - report_msg = await report_channel.send(embed=report, file=contents) - await self.db.scam_reports.update_one({"message_id": message.id}, {"$set": {"report_id": report_msg.id}}) - - moderator = await self.bot.get_or_fetch_user(cfg["rocketpool.support.moderator_id"]) - view = self.RemovalVoteView(self, message) - warning_msg = await message.reply( - content=f"{moderator.mention} {report_msg.jump_url}", - embed=warning, - view=view, - mention_author=False - ) - await self.db.scam_reports.update_one({"message_id": message.id}, {"$set": {"warning_id": warning_msg.id}}) - await interaction.followup.send(content="Thanks for reporting!") - - def _markdown_link_trick(self, message: Message) -> Optional[str]: - txt = self._get_message_content(message) - for m in self.markdown_link_pattern.findall(txt): - if "." in m[0] and m[0] != m[1]: - return "Markdown link with possible domain in visible portion that does not match the actual domain" - return None - - def _discord_invite(self, message: Message) -> Optional[str]: - txt = self._get_message_content(message) - if self.invite_pattern.search(txt): - return "Invite to external server" - return None - - def _ticket_system(self, message: Message) -> Optional[str]: - # message contains one of the relevant keyword combinations and a link - txt = self._get_message_content(message) - if not self.basic_url_pattern.search(txt): - return None - - keywords = ( - [ - ("open", "create", "raise", "raisse"), - "ticket" - ], - [ - ("contact", "reach out", "report", [("talk", "speak"), ("to", "with")], "ask"), - ("admin", "mod", "administrator", "moderator") - ], - ("support team", "supp0rt", "🎫", "🎟️", "m0d"), - [ - ("ask", "seek", "request", "contact"), - ("help", "assistance", "service", "support") - ], - [ - ("instant", "live"), - "chat" - ] - ) - - def txt_contains(_x: list | tuple | str) -> bool: - match _x: - case str(): - return (re.search(rf"\b{_x}\b", txt) is not None) - case tuple(): - return any(map(txt_contains, _x)) - case list(): - return all(map(txt_contains, _x)) - return False - - return "There is no ticket system in this server." if txt_contains(keywords) else None - - def _paperhands(self, message: Message) -> Optional[str]: - # message contains the word "paperhand" and a link - txt = self._get_message_content(message) - # if has http and contains the word paperhand or paperhold - if (any(x in txt for x in ["paperhand", "paper hand", "paperhold", "pages.dev", "web.app"]) and "http" in txt) or "pages.dev" in txt: - return "The linked website is most likely a wallet drainer" - return None - - # contains @here or @everyone but doesn't actually have the permission to do so - def _mention_everyone(self, message: Message) -> Optional[str]: - txt = self._get_message_content(message) - if ("@here" in txt or "@everyone" in txt) and not message.author.guild_permissions.mention_everyone: - return "Mentioned @here or @everyone without permission" - return None - - async def _reaction_spam(self, reaction: Reaction, user: User) -> Optional[str]: - # user reacts to their own message multiple times in quick succession to draw attention - # check if user is a bot - if user.bot: - log.debug(f"Ignoring reaction by bot {user.id}") - return None - - # check if the reaction is by the same user that created the message - if reaction.message.author != user: - log.debug(f"Ignoring reaction by non-author {user.id}") - return None - - # check if the message is new enough (we ignore any reactions on messages older than 5 minutes) - if (reaction.message.created_at - datetime.now(timezone.utc)) > timedelta(minutes=5): - log.debug(f"Ignoring reaction on old message {reaction.message.id}") - return None - - # get all reactions on message - reactions = self._message_react_cache.get(reaction.message.id) - if reactions is None: - reactions = {} - for msg_reaction in reaction.message.reactions: - reactions[msg_reaction.emoji] = {user async for user in msg_reaction.users()} - self._message_react_cache[reaction.message.id] = reactions - elif reaction.emoji not in reactions: - reactions[reaction.emoji] = {user} - else: - reactions[reaction.emoji].add(user) - - reaction_count = len([r for r in reactions.values() if user in r and len(r) == 1]) - log.debug(f"{reaction_count} reactions on message {reaction.message.id}") - # if there are 8 reactions done by the author of the message, report it - return "Reaction spam by message author" if (reaction_count >= 8) else None - - @Cog.listener() - async def on_message(self, message: Message) -> None: - if message.author.bot: - log.warning("Ignoring message sent by bot") - return - - if self.is_reputable(message.author): - log.warning(f"Ignoring message sent by trusted user ({message.author})") - return - - if message.guild is None: - return - - if message.guild.id != cfg["rocketpool.support.server_id"]: - log.warning(f"Ignoring message in {message.guild.id})") - return - - checks = [ - self._ticket_system, - self._markdown_link_trick, - self._paperhands, - self._discord_invite, - self._mention_everyone, - ] - for check in checks: - if reason := check(message): - await self.report_message(message, reason) - return - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - await self.on_message(after) - - @Cog.listener() - async def on_reaction_add(self, reaction: Reaction, user: User) -> None: - if reaction.message.guild.id != cfg["rocketpool.support.server_id"]: - log.warning(f"Ignoring reaction in {reaction.message.guild.id}") - return - - checks = [ - self._reaction_spam(reaction, user) - ] - for reason in await asyncio.gather(*checks): - if reason: - await self.report_message(reaction.message, reason) - return - - @Cog.listener() - async def on_raw_message_delete(self, event: RawMessageDeleteEvent) -> None: - async with self._update_lock: - await self._on_message_delete(event.message_id) - - @Cog.listener() - async def on_raw_bulk_message_delete(self, event: RawBulkMessageDeleteEvent) -> None: - async with self._update_lock: - await asyncio.gather(*[self._on_message_delete(msg_id) for msg_id in event.message_ids]) - - async def _on_message_delete(self, message_id: int) -> None: - db_filter = {"type": "message", "message_id": message_id, "removed": False} - if not (report := await self.db.scam_reports.find_one(db_filter)): - return - - channel = await self.bot.get_or_fetch_channel(report["channel_id"]) - with contextlib.suppress(errors.NotFound, errors.Forbidden): - message = await channel.fetch_message(report["warning_id"]) - await message.delete() - - await self._update_report(report, "Original message has been deleted.") - await self.db.scam_reports.update_one(db_filter, {"$set": {"warning_id": None, "removed": True}}) - - @Cog.listener() - async def on_member_ban(self, guild: Guild, user: User) -> None: - async with self._update_lock: - reports = await self.db.scam_reports.find( - {"guild_id": guild.id, "user_id": user.id, "user_banned": False} - ).to_list(None) - for report in reports: - await self._update_report(report, "User has been banned.") - await self.db.scam_reports.update_one(report, {"$set": {"user_banned": True}}) - - async def _update_report(self, report: dict, note: str) -> None: - report_channel = await self.bot.get_or_fetch_channel(cfg["discord.channels.report_scams"]) - try: - message = await report_channel.fetch_message(report["report_id"]) - embed = message.embeds[0] - embed.description += f"\n\n**{note}**" - embed.color = self.Color.WARN if (embed.color == self.Color.ALERT) else self.Color.OK - await message.edit(embed=embed) - except Exception as e: - await self.bot.report_error(e) - - async def report_thread(self, thread: Thread, reason: str) -> None: - if not (components := await self._generate_thread_report(thread, reason)): - return None - - warning, report = components - - try: - view = self.RemovalVoteView(self, thread) - warning_msg = await thread.send(embed=warning, view=view) - except errors.Forbidden: - log.warning(f"Failed to send warning message in thread {thread.id}") - warning_msg = None - - report_channel = await self.bot.get_or_fetch_channel(cfg["discord.channels.report_scams"]) - report_msg = await report_channel.send(embed=report) - - await self.db.scam_reports.update_one( - {"channel_id": thread.id, "message_id": None}, - {"$set": {"warning_id": warning_msg.id if warning_msg else None, "report_id": report_msg.id}} - ) - - @Cog.listener() - async def on_thread_create(self, thread: Thread) -> None: - if thread.guild.id != cfg["rocketpool.support.server_id"]: - log.warning(f"Ignoring thread creation in {thread.guild.id}") - return - - keywords = ("support", "ticket", "assistance", "🎫", "🎟️") - if not any(kw in thread.name.lower() for kw in keywords): - log.debug(f"Ignoring thread creation (id: {thread.id}, name: {thread.name})") - return - - await self.report_thread(thread, "Illegitimate support thread") - - @Cog.listener() - async def on_raw_thread_update(self, event: RawThreadUpdateEvent) -> None: - thread: Thread = await self.bot.get_or_fetch_channel(event.thread_id) - await self.on_thread_create(thread) - - @Cog.listener() - async def on_raw_thread_delete(self, event: RawThreadDeleteEvent) -> None: - async with self._update_lock: - db_filter = {"type": "thread", "channel_id": event.thread_id, "removed": False} - if report := await self.db.scam_reports.find_one(db_filter): - await self._update_report(report, "Thread has been deleted.") - await self.db.scam_reports.update_one(db_filter, {"$set": {"warning_id": None, "removed": True}}) - - @command() - @guilds(cfg["rocketpool.support.server_id"]) - async def report_user(self, interaction: Interaction, user: Member) -> None: - """Generate a suspicious user report and send it to the report channel""" - await self.manual_user_report(interaction, user) - - async def manual_user_report(self, interaction: Interaction, user: Member) -> None: - await interaction.response.defer(ephemeral=True) - - if user.bot: - return await interaction.followup.send(content="Bots can't be reported.") - - if user == interaction.user: - return await interaction.followup.send(content="Did you just report yourself?") - - reason = f"Manual report by {interaction.user.mention}" - if not (report := await self._generate_user_report(user, reason)): - return await interaction.followup.send( - content="Failed to report user. They may have already been reported or banned." - ) - - report_channel = await self.bot.get_or_fetch_channel(cfg["discord.channels.report_scams"]) - report_msg = await report_channel.send(embed=report) - - await self.db.scam_reports.update_one( - {"guild_id": user.guild.id, "user_id": user.id, "channel_id": None, "message_id": None}, - {"$set": {"report_id": report_msg.id}} - ) - await interaction.followup.send(content="Thanks for reporting!") - - async def _generate_user_report(self, user: Member, reason: str) -> Optional[Embed]: - if not isinstance(user, Member): - return None - - async with self._report_lock: - if await self.db.scam_reports.find_one( - {"type": "user", "guild_id": user.guild.id, "user_id": user.id} - ): - log.info(f"Found existing report for user {user.id} in database") - return None - - report = Embed(title="🚨 Suspicious User Detected") - report.color = self.Color.ALERT - report.description = f"**Reason**: {reason}\n" - report.description += ( - "\n" - f"Name: `{user.display_name}`\n" - f"ID: `{user.id}` ({user.mention})\n" - f"Roles: [{', '.join(role.mention for role in user.roles[1:])}]\n" - "\n" - "Please review and take appropriate action." - ) - report.set_thumbnail(url=user.display_avatar.url) - - await self.db.scam_reports.insert_one({ - "type" : "user", - "guild_id" : user.guild.id, - "user_id" : user.id, - "reason" : reason, - "content" : user.display_name, - "warning_id" : None, - "report_id" : None, - "user_banned": False, - }) - return report - - -async def setup(bot): - await bot.add_cog(DetectScam(bot)) diff --git a/rocketwatch/plugins/event_core/event_core.py b/rocketwatch/plugins/event_core/event_core.py index 97b0516f..e8c8674e 100644 --- a/rocketwatch/plugins/event_core/event_core.py +++ b/rocketwatch/plugins/event_core/event_core.py @@ -1,31 +1,28 @@ -import time -import pickle import asyncio import logging - -from concurrent.futures import ThreadPoolExecutor +import pickle +import time from datetime import datetime, timedelta from enum import Enum -from functools import partial -from typing import Optional, Any +from typing import Any +import discord import pymongo from cronitor import Monitor +from discord.abc import Messageable from discord.ext import commands, tasks -from eth_typing import BlockIdentifier, BlockNumber -from motor.motor_asyncio import AsyncIOMotorClient +from eth_typing import BlockNumber from web3.datastructures import MutableAttributeDict -from rocketwatch import RocketWatch from plugins.support_utils.support_utils import generate_template_embed -from utils.status import StatusPlugin -from utils.cfg import cfg -from utils.embeds import assemble, Embed +from rocketwatch import RocketWatch +from utils.config import cfg +from utils.embeds import Embed, assemble from utils.event import EventPlugin from utils.shared_w3 import w3 +from utils.status import StatusPlugin -log = logging.getLogger("event_core") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.event_core") class EventCore(commands.Cog): @@ -39,18 +36,18 @@ def __str__(self) -> str: def __init__(self, bot: RocketWatch): self.bot = bot self.state = self.State.OK - self.channels = cfg["discord.channels"] - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch - self.head_block: BlockIdentifier = cfg["events.genesis"] - self.block_batch_size = cfg["events.block_batch_size"] - self.monitor = Monitor("gather-new-events", api_key=cfg["other.secrets.cronitor"]) - self.loop.start() - - def cog_unload(self) -> None: - self.loop.cancel() - - @tasks.loop(seconds=12) - async def loop(self) -> None: + self.channels = cfg.discord.channels + self.head_block: BlockNumber = BlockNumber(cfg.events.genesis) + self.at_head: bool = False + self.block_batch_size: int = cfg.events.block_batch_size + self.monitor = Monitor("event-core", api_key=cfg.other.secrets.cronitor) + self.task.start() + + async def cog_unload(self) -> None: + self.task.cancel() + + @tasks.loop(seconds=30) + async def task(self) -> None: p_id = time.time() self.monitor.ping(state="run", series=p_id) @@ -61,23 +58,23 @@ async def loop(self) -> None: await self.on_success() self.monitor.ping(state="complete", series=p_id) except Exception as error: - await self.on_error(error) self.monitor.ping(state="fail", series=p_id) + await self.on_error(error) - @loop.before_loop + @task.before_loop async def before_loop(self) -> None: await self.bot.wait_until_ready() async def on_success(self) -> None: if self.state == self.State.ERROR: self.state = self.State.OK - self.loop.change_interval(seconds=12) + self.task.change_interval(seconds=12) async def on_error(self, error: Exception) -> None: await self.bot.report_error(error) if self.state == self.State.OK: self.state = self.State.ERROR - self.loop.change_interval(seconds=30) + self.task.change_interval(seconds=30) try: await self.show_service_interrupt() @@ -88,135 +85,164 @@ async def gather_new_events(self) -> None: log.info("Gathering messages from submodules") log.debug(f"{self.head_block = }") - latest_block = w3.eth.get_block_number() - submodules = [cog for cog in self.bot.cogs.values() if isinstance(cog, EventPlugin)] + latest_block = await w3.eth.get_block_number() + submodules = [ + cog for cog in self.bot.cogs.values() if isinstance(cog, EventPlugin) + ] log.debug(f"Running {len(submodules)} submodules") - if self.head_block == "latest": + if self.at_head: # already caught up to head, just fetch new events - target_block = "latest" + close_to_head = True to_block = latest_block - gather_fns = [sm.get_new_events for sm in submodules] + coroutines = [sm.get_new_events() for sm in submodules] # prevent losing state if process is interrupted before updating db - self.head_block = cfg["events.genesis"] + self.at_head = False + self.head_block = cfg.events.genesis else: # behind chain head, let's see how far - last_event_entry = await self.db.event_queue.find().sort( - "block_number", pymongo.DESCENDING - ).limit(1).to_list(None) + last_event_entry = ( + await self.bot.db.event_queue.find() + .sort("block_number", pymongo.DESCENDING) + .limit(1) + .to_list(None) + ) if last_event_entry: - self.head_block = max(self.head_block, last_event_entry[0]["block_number"]) + self.head_block = max( + self.head_block, last_event_entry[0]["block_number"] + ) - last_checked_entry = await self.db.last_checked_block.find_one({"_id": "events"}) + last_checked_entry = await self.bot.db.last_checked_block.find_one( + {"_id": "events"} + ) if last_checked_entry: self.head_block = max(self.head_block, last_checked_entry["block"]) if (latest_block - self.head_block) < self.block_batch_size: # close enough to catch up in a single request - target_block = "latest" + close_to_head = True to_block = latest_block else: # too far, advance one batch - target_block = self.head_block + self.block_batch_size - to_block = target_block + close_to_head = False + to_block = BlockNumber(self.head_block + self.block_batch_size) - from_block: BlockNumber = self.head_block + 1 + from_block = BlockNumber(self.head_block + 1) if to_block < from_block: log.warning(f"Skipping empty block range [{from_block}, {to_block}]") return log.info(f"Checking block range [{from_block}, {to_block}]") - gather_fns = [] + coroutines = [] for sm in submodules: - fn = partial(sm.get_past_events, from_block=from_block, to_block=to_block) - gather_fns.append(fn) - if target_block == "latest": - sm.start_tracking(to_block + 1) + coroutines.append( + sm.get_past_events(from_block=from_block, to_block=to_block) + ) + if close_to_head: + sm.start_tracking(BlockNumber(to_block + 1)) - log.debug(f"{target_block = }") + log.debug(f"{close_to_head = }, {to_block = }") - with ThreadPoolExecutor() as executor: - loop = asyncio.get_running_loop() - futures = [loop.run_in_executor(executor, gather_fn) for gather_fn in gather_fns] - results = await asyncio.gather(*futures) + results = await asyncio.gather(*coroutines) - channels = cfg["discord.channels"] + channels = self.channels events: list[dict[str, Any]] = [] for result in results: for event in result: - if await self.db.event_queue.find_one({"_id": event.unique_id}): + if await self.bot.db.event_queue.find_one({"_id": event.unique_id}): log.debug(f"Event {event} already exists, skipping") continue # select channel dynamically from config based on event_name prefix - channel_candidates = [value for key, value in channels.items() if event.event_name.startswith(key)] - channel_id = channel_candidates[0] if channel_candidates else channels["default"] - events.append({ - "_id": event.unique_id, - "embed": pickle.dumps(event.embed), - "topic": event.topic, - "event_name": event.event_name, - "block_number": event.block_number, - "score": event.get_score(), - "time_seen": datetime.now(), - "image": pickle.dumps(event.image) if event.image else None, - "thumbnail": pickle.dumps(event.thumbnail) if event.thumbnail else None, - "channel_id": channel_id, - "message_id": None - }) + channel_candidates = [ + value + for key, value in channels.items() + if event.event_name.startswith(key) + ] + channel_id = ( + channel_candidates[0] if channel_candidates else channels["default"] + ) + events.append( + { + "_id": event.unique_id, + "embed": pickle.dumps(event.embed), + "topic": event.topic, + "event_name": event.event_name, + "block_number": event.block_number, + "score": event.get_score(), + "time_seen": datetime.now(), + "image": pickle.dumps(event.image) if event.image else None, + "thumbnail": pickle.dumps(event.thumbnail) + if event.thumbnail + else None, + "channel_id": channel_id, + "message_id": None, + } + ) log.info(f"{len(events)} new events gathered, updating DB") if events: - await self.db.event_queue.insert_many(events) + await self.bot.db.event_queue.insert_many(events) - self.head_block = target_block - self.db.last_checked_block.replace_one( - {"_id": "events"}, - {"_id": "events", "block": to_block}, - upsert=True + self.head_block = to_block + self.at_head = close_to_head + await self.bot.db.last_checked_block.replace_one( + {"_id": "events"}, {"_id": "events", "block": to_block}, upsert=True ) async def process_event_queue(self) -> None: log.debug("Processing events in queue") # get all channels with unprocessed events - channels = await self.db.event_queue.distinct("channel_id", {"message_id": None}) + channels = await self.bot.db.event_queue.distinct( + "channel_id", {"message_id": None} + ) if not channels: log.debug("No pending events in queue") return - def try_load(_entry: dict, _key: str) -> Optional[Any]: + async def try_load(_entry: dict, _key: str) -> Any | None: try: serialized = _entry.get(_key) return pickle.loads(serialized) if serialized else None except Exception as err: - self.bot.report_error(err) + await self.bot.report_error(err) return None for channel_id in channels: - db_events: list[dict] = await self.db.event_queue.find( - {"channel_id": channel_id, "message_id": None} - ).sort("score", pymongo.ASCENDING).to_list(None) + db_events: list[dict] = ( + await self.bot.db.event_queue.find( + {"channel_id": channel_id, "message_id": None} + ) + .sort("score", pymongo.ASCENDING) + .to_list(None) + ) log.debug(f"Found {len(db_events)} events for channel {channel_id}.") channel = await self.bot.get_or_fetch_channel(channel_id) + assert isinstance(channel, Messageable) - for state_message in await self.db.state_messages.find({"channel_id": channel_id}).to_list(None): + for state_message in await self.bot.db.state_messages.find( + {"channel_id": channel_id} + ).to_list(None): msg = await channel.fetch_message(state_message["message_id"]) await msg.delete() - await self.db.state_messages.delete_one({"channel_id": channel_id}) + await self.bot.db.state_messages.delete_one({"channel_id": channel_id}) for event_entry in db_events: - embed: Optional[Embed] = try_load(event_entry, "embed") + embed: Embed | None = await try_load(event_entry, "embed") + if not embed: + continue + files = [] - if embed and (image := try_load(event_entry, "image")): + if embed and (image := await try_load(event_entry, "image")): file_name = f"{event_entry['event_name']}_img.png" files.append(image.to_file(file_name)) embed.set_image(url=f"attachment://{file_name}") - if embed and (thumbnail := try_load(event_entry, "thumbnail")): + if embed and (thumbnail := await try_load(event_entry, "thumbnail")): file_name = f"{event_entry['event_name']}_thumb.png" files.append(thumbnail.to_file(file_name)) embed.set_thumbnail(url=f"attachment://{file_name}") @@ -224,92 +250,111 @@ def try_load(_entry: dict, _key: str) -> Optional[Any]: # post event message msg = await channel.send(embed=embed, files=files) # add message id to event - await self.db.event_queue.update_one( - {"_id": event_entry["_id"]}, - {"$set": {"message_id": msg.id}} + await self.bot.db.event_queue.update_one( + {"_id": event_entry["_id"]}, {"$set": {"message_id": msg.id}} ) log.info("Processed all events in queue") async def update_status_messages(self) -> None: - configs = cfg.get("events.status_message", {}) - for state_message in (await self.db.state_messages.find().to_list(None)): + configs = cfg.events.status_message + for state_message in await self.bot.db.state_messages.find().to_list(): if state_message["_id"] not in configs: - log.debug(f"No config for state message ID {state_message['_id']}, removing message") + log.debug( + f"No config for state message ID {state_message['_id']}, removing message" + ) await self._replace_or_add_status("", None, state_message) for channel_name, config in configs.items(): log.debug(f"Updating state message for channel {channel_name}") await self._update_status_message(channel_name, config) - async def _update_status_message(self, channel_name: str, config: dict) -> None: - state_message = await self.db.state_messages.find_one({"_id": channel_name}) + async def _update_status_message(self, channel_name: str, config) -> None: + state_message = await self.bot.db.state_messages.find_one({"_id": channel_name}) if state_message: age = datetime.now() - state_message["sent_at"] - cooldown = timedelta(seconds=config["cooldown"]) + cooldown = timedelta(seconds=config.cooldown) if (age < cooldown) and (state_message["state"] == str(self.State.OK)): - log.debug(f"State message for {channel_name} not past cooldown: {age} < {cooldown}") + log.debug( + f"State message for {channel_name} not past cooldown: {age} < {cooldown}" + ) return - if not (embed := await generate_template_embed(self.db, "announcement")): + if not (embed := await generate_template_embed(self.bot.db, "announcement")): try: - plugin: StatusPlugin = self.bot.cogs.get(config["plugin"]) + plugin = self.bot.cogs.get(config.plugin) + assert isinstance(plugin, StatusPlugin) embed = await plugin.get_status() except Exception as err: await self.bot.report_error(err) return embed.timestamp = datetime.now() - embed.set_footer(text=f"Tracking {cfg['rocketpool.chain']} using {len(self.bot.cogs)} plugins") - for field in config["fields"]: + embed.set_footer( + text=f"Tracking {cfg.rocketpool.chain} using {len(self.bot.cogs)} plugins" + ) + for field in config.fields: embed.add_field(**field) await self._replace_or_add_status(channel_name, embed, state_message) async def show_service_interrupt(self) -> None: - embed = assemble(MutableAttributeDict({"event_name": "service_interrupted"})) - for channel_name in cfg.get("events.status_message", {}).keys(): - state_message = await self.db.state_messages.find_one({"_id": channel_name}) + embed = await assemble( + MutableAttributeDict({"event_name": "service_interrupted"}) + ) + for channel_name in cfg.events.status_message: + state_message = await self.bot.db.state_messages.find_one( + {"_id": channel_name} + ) if (not state_message) or (state_message["state"] != str(self.state.ERROR)): await self._replace_or_add_status(channel_name, embed, state_message) async def _replace_or_add_status( - self, - target_channel: str, - embed: Optional[Embed], - prev_status: Optional[dict] + self, target_channel: str, embed: Embed | None, prev_status: dict | None ) -> None: - target_channel_id = self.channels.get(target_channel) or self.channels["default"] + target_channel_id = ( + self.channels.get(target_channel) or self.channels["default"] + ) if embed and prev_status and (prev_status["channel_id"] == target_channel_id): log.debug(f"Replacing existing status message for channel {target_channel}") channel = await self.bot.get_or_fetch_channel(target_channel_id) - msg = await channel.fetch_message(prev_status["message_id"]) - await msg.edit(embed=embed) - await self.db.state_messages.update_one( - prev_status, - {"$set": {"sent_at": datetime.now(), "state": str(self.state)}} - ) - return + assert isinstance(channel, Messageable) + try: + msg = await channel.fetch_message(prev_status["message_id"]) + await msg.edit(embed=embed) + await self.bot.db.state_messages.update_one( + prev_status, + {"$set": {"sent_at": datetime.now(), "state": str(self.state)}}, + ) + return + except discord.errors.NotFound: + log.warning("Could not fetch status, removing DB entry") + await self.bot.db.state_messages.delete_one(prev_status) + prev_status = None if prev_status: log.debug(f"Deleting status message for channel {target_channel}") channel = await self.bot.get_or_fetch_channel(prev_status["channel_id"]) + assert isinstance(channel, Messageable) msg = await channel.fetch_message(prev_status["message_id"]) await msg.delete() - await self.db.state_messages.delete_one(prev_status) + await self.bot.db.state_messages.delete_one(prev_status) if embed: log.debug(f"Creating new status message for channel {target_channel}") channel = await self.bot.get_or_fetch_channel(target_channel_id) + assert isinstance(channel, Messageable) msg = await channel.send(embed=embed, silent=True) - await self.db.state_messages.insert_one({ - "_id" : target_channel, - "channel_id": target_channel_id, - "message_id": msg.id, - "sent_at" : datetime.now(), - "state" : str(self.state) - }) + await self.bot.db.state_messages.insert_one( + { + "_id": target_channel, + "channel_id": target_channel_id, + "message_id": msg.id, + "sent_at": datetime.now(), + "state": str(self.state), + } + ) async def setup(bot): diff --git a/rocketwatch/plugins/events/events.json b/rocketwatch/plugins/events/events.json index b0593a95..d4eba6e5 100644 --- a/rocketwatch/plugins/events/events.json +++ b/rocketwatch/plugins/events/events.json @@ -81,6 +81,10 @@ { "event_name": "DepositRecycled", "name": "pool_deposit_recycled_event" + }, + { + "event_name": "QueueExited", + "name": "validator_queue_exited_event" } ] }, @@ -158,6 +162,10 @@ { "event_name": "Transfer", "name": "rpl_transfer_event" + }, + { + "event_name": "RPLFixedSupplyBurn", + "name": "rpl_migration_event" } ] }, @@ -195,7 +203,7 @@ "contract_name": "rocketNodeStaking", "events": [ { - "event_name": "RPLStaked", + "event_name": "RPLStaked(address,address,uint256,uint256)", "name": "rpl_stake_event" }, { @@ -226,23 +234,6 @@ } ] }, - { - "contract_name": "rocketMinipoolBondReducer", - "events": [ - { - "event_name": "CancelReductionVoted", - "name": "minipool_vote_against_bond_reduction_event" - }, - { - "event_name": "ReductionCancelled", - "name": "minipool_bond_reduction_cancelled_event" - }, - { - "event_name": "BeginBondReduction", - "name": "minipool_bond_reduction_started_event" - } - ] - }, { "contract_name": "rocketDAOProtocol", "events": [ @@ -306,6 +297,14 @@ { "event_name": "Withdrawal", "name": "eth_withdraw_event" + }, + { + "event_name": "DepositReceived", + "name": "validator_deposit_event" + }, + { + "event_name": "MultiDepositReceived", + "name": "validator_multi_deposit_event" } ] }, @@ -452,6 +451,19 @@ "name": "cs_rpl_target_ratio_change_event" } ] + }, + { + "contract_name": "RockSolidVault", + "events": [ + { + "event_name": "DepositSync", + "name": "rocksolid_deposit_event" + }, + { + "event_name": "RedeemRequest", + "name": "rocksolid_withdrawal_event" + } + ] } ], "global": [ @@ -483,17 +495,50 @@ "name": "minipool_status_updated_event" } ] + }, + { + "contract_name": "rocketMegapoolDelegate", + "events": [ + { + "event_name": "MegapoolValidatorAssigned", + "name": "megapool_validator_assigned_event" + }, + { + "event_name": "MegapoolValidatorExiting", + "name": "megapool_validator_exiting_event" + }, + { + "event_name": "MegapoolValidatorExited", + "name": "megapool_validator_exited_event" + }, + { + "event_name": "MegapoolValidatorDissolved", + "name": "megapool_validator_dissolve_event" + }, + { + "event_name": "MegapoolPenaltyApplied", + "name": "megapool_penalty_event" + } + ] }, { "contract_name": "rocketDAONodeTrustedUpgrade", "events": [ + { + "event_name": "UpgradePending", + "name": "odao_upgrade_pending_event" + }, + { + "event_name": "UpgradeVetoed", + "name": "sdao_upgrade_vetoed_event" + }, { "event_name": "ContractUpgraded", - "name": "contract_upgraded" + "name": "odao_contract_upgraded_event" }, { "event_name": "ContractAdded", - "name": "contract_added" + "name": "odao_contract_added_event" } ] } diff --git a/rocketwatch/plugins/events/events.py b/rocketwatch/plugins/events/events.py index 81077bdf..14a9efe8 100644 --- a/rocketwatch/plugins/events/events.py +++ b/rocketwatch/plugins/events/events.py @@ -1,46 +1,54 @@ -import json import hashlib +import json import logging import warnings -from typing import Optional, Callable, Literal +from collections.abc import Callable, Coroutine +from typing import Literal from discord import Interaction -from discord.ext.commands import is_owner from discord.app_commands import command, guilds -from eth_typing import ChecksumAddress, BlockNumber +from discord.ext.commands import is_owner +from eth_typing.evm import BlockNumber, ChecksumAddress from hexbytes import HexBytes -from web3._utils.filters import Filter +from web3.constants import ADDRESS_ZERO from web3.datastructures import MutableAttributeDict as aDict -from web3.exceptions import ABIEventFunctionNotFound -from web3.types import LogReceipt, EventData, FilterParams +from web3.logs import DISCARD +from web3.types import EventData, LogReceipt from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg +from utils.block_time import block_to_ts +from utils.config import cfg from utils.dao import DefaultDAO, ProtocolDAO -from utils.embeds import assemble, prepare_args, el_explorer_url, Embed -from utils.event import EventPlugin, Event -from utils.rocketpool import rp, NoAddressFound -from utils.shared_w3 import w3, bacon +from utils.embeds import Embed, assemble, el_explorer_url, prepare_args +from utils.event import Event, EventPlugin +from utils.rocketpool import NoAddressFound, rp +from utils.shared_w3 import bacon, w3 from utils.solidity import SUBMISSION_KEYS -from utils.block_time import block_to_ts -log = logging.getLogger("events") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.events") -PartialFilter = Callable[[BlockNumber, BlockNumber | Literal["latest"]], Filter] +PartialFilter = Callable[ + [BlockNumber, BlockNumber | Literal["latest"]], + Coroutine[None, None, list[LogReceipt | EventData]], +] + class Events(EventPlugin): def __init__(self, bot: RocketWatch): super().__init__(bot) - partial_filters, event_map, topic_map = self._parse_event_config() + self._partial_filters = [] + self.event_map = {} + self.topic_map = {} + + async def async_init(self): + partial_filters, event_map, topic_map = await self._parse_event_config() self._partial_filters = partial_filters self.event_map = event_map self.topic_map = topic_map - self.active_filters: list[Filter] = [] - def _parse_event_config(self) -> tuple[list[PartialFilter], dict, dict]: + async def _parse_event_config(self) -> tuple[list[PartialFilter], dict, dict]: with open("./plugins/events/events.json") as f: config = json.load(f) @@ -54,7 +62,7 @@ def _parse_event_config(self) -> tuple[list[PartialFilter], dict, dict]: for group in config["direct"]: contract_name = group["contract_name"] try: - contract = rp.get_contract_by_name(contract_name) + contract = await rp.get_contract_by_name(contract_name) addresses.add(contract.address) except NoAddressFound: log.warning(f"Failed to get contract {contract_name}") @@ -63,10 +71,15 @@ def _parse_event_config(self) -> tuple[list[PartialFilter], dict, dict]: for event in group["events"]: event_name = event["event_name"] try: - topic = contract.events[event_name].build_filter().topics[0] - except ABIEventFunctionNotFound as e: - self.bot.report_error(e) - log.warning(f"Couldn't find event {event_name} ({event['name']}) in the contract") + log.info(f"Adding filter for {contract_name}.{event_name}") + event_abi = contract.events[event_name].abi + input_types = ",".join(i["type"] for i in event_abi["inputs"]) + topic = w3.keccak(text=f"{event_name}({input_types})").hex() + except Exception as e: + log.exception(e) + log.warning( + f"Couldn't find event {event_name} ({event['name']}) in the contract" + ) continue aggregated_topics.add(topic) @@ -74,81 +87,118 @@ def _parse_event_config(self) -> tuple[list[PartialFilter], dict, dict]: topic_map[topic] = event_name if addresses: - def build_direct_filter(_from: BlockNumber, _to: BlockNumber | Literal["latest"]) -> Filter: - filter_params: FilterParams = { - "address" : list(addresses), - "topics" : [list(aggregated_topics)], - "fromBlock": _from, - "toBlock" : _to - } - return w3.eth.filter(filter_params) + + async def build_direct_filter( + _from: BlockNumber, _to: BlockNumber | Literal["latest"] + ) -> list[LogReceipt]: + return await w3.eth.get_logs( + { + "address": list(addresses), + "topics": [list(aggregated_topics)], + "fromBlock": _from, + "toBlock": _to, + } + ) + partial_filters.append(build_direct_filter) - # generate filters for global events + # generate filter for global events + global_topics: set[HexBytes] = set() + global_topic_decoders: dict[str, type] = {} for group in config["global"]: - contract = rp.assemble_contract(name=group["contract_name"]) + try: + contract = await rp.get_contract_by_name(name=group["contract_name"]) + except Exception as e: + log.warning(f"Failed to get contract {group['contract_name']}: {e}") + continue + for event in group["events"]: - event_map[event["event_name"]] = event["name"] - def super_builder(_contract, _event) -> PartialFilter: - # this is needed to pin nonlocal variables - def build_topic_filter(_from: BlockNumber, _to: BlockNumber | Literal["latest"]) -> Filter: - return _contract.events[_event["event_name"]].createFilter( - fromBlock=_from, - toBlock=_to, - argument_filters=_event.get("filter", {}) - ) - return build_topic_filter - partial_filters.append(super_builder(contract, event)) + event_name = event["event_name"] + event_map[event_name] = event["name"] + + try: + event_cls = contract.events[event_name] + event_abi = event_cls.abi + input_types = ",".join(i["type"] for i in event_abi["inputs"]) + topic = w3.keccak(text=f"{event_name}({input_types})").hex() + except Exception as e: + log.exception(e) + log.warning(f"Couldn't find global event {event_name}") + continue + + global_topics.add(topic) + global_topic_decoders[topic] = event_cls + + if global_topics: + + async def build_global_filter( + _from: BlockNumber, _to: BlockNumber | Literal["latest"] + ) -> list[EventData]: + raw_logs = await w3.eth.get_logs( + { + "topics": [list(global_topics)], + "fromBlock": _from, + "toBlock": _to, + } + ) + return [ + global_topic_decoders[raw_log["topics"][0].hex()]().process_log( + raw_log + ) + for raw_log in raw_logs + ] + + partial_filters.append(build_global_filter) return partial_filters, event_map, topic_map @command() - @guilds(cfg["discord.owner.server_id"]) + @guilds(cfg.discord.owner.server_id) @is_owner() async def trigger_event( - self, - interaction: Interaction, - contract: str, - event: str, - json_args: str = "{}", - block_number: int = 0 + self, + interaction: Interaction, + contract: str, + event: str, + json_args: str = "{}", + block_number: int = 0, ): await interaction.response.defer() try: - default_args = { - "tnx_fee": 0, - "tnx_fee_usd": 0 - } - event_obj = aDict({ - "event": event, - "transactionHash": aDict({"hex": lambda: '0x0000000000000000000000000000000000000000'}), - "blockNumber": block_number, - "args": aDict(default_args | json.loads(json_args)) - }) + default_args = {"tnx_fee": 0, "tnx_fee_usd": 0} + event_obj = aDict( + { + "event": event, + "transactionHash": aDict({"hex": lambda: "0" * 64}), + "blockNumber": block_number, + "args": aDict(default_args | json.loads(json_args)), + } + ) except json.JSONDecodeError: return await interaction.followup.send(content="Invalid JSON args!") if not (event_name := self.event_map.get(event, None)): event_name = self.event_map[f"{contract}.{event}"] - if embed := self.handle_event(event_name, event_obj): + if embed := await self.handle_event(event_name, event_obj): await interaction.followup.send(embed=embed) else: await interaction.followup.send(content="No events triggered.") @command() - @guilds(cfg["discord.owner.server_id"]) + @guilds(cfg.discord.owner.server_id) @is_owner() async def replay_events(self, interaction: Interaction, tx_hash: str): await interaction.response.defer() - receipt = w3.eth.get_transaction_receipt(tx_hash) + receipt = await w3.eth.get_transaction_receipt(tx_hash) logs: list[LogReceipt] = receipt.logs filtered_events: list[LogReceipt | EventData] = [] # get direct events for event_log in logs: - if ("topics" in event_log) and (event_log["topics"][0].hex() in self.topic_map): + topics = event_log.get("topics", []) + if topics and (topics[0].hex() in self.topic_map): filtered_events.append(event_log) # get global events @@ -156,65 +206,64 @@ async def replay_events(self, interaction: Interaction, tx_hash: str): global_events = json.load(f)["global"] for group in global_events: - contract = rp.assemble_contract(name=group["contract_name"]) + contract = await rp.assemble_contract(name=group["contract_name"]) for event in group["events"]: event = contract.events[event["event_name"]]() - rich_logs = event.process_receipt(receipt) + rich_logs = event.process_receipt(receipt, errors=DISCARD) filtered_events.extend(rich_logs) - responses, _ = self.process_events(filtered_events) + responses, _ = await self.process_events(filtered_events) if responses: - await interaction.followup.send(embeds=[response.embed for response in responses]) + await interaction.followup.send( + embeds=[response.embed for response in responses] + ) else: await interaction.followup.send(content="No events found.") - def _get_new_events(self) -> list[Event]: - if not self.active_filters: - from_block = self.last_served_block + 1 - self.lookback_distance - self.active_filters = [pf(from_block, "latest") for pf in self._partial_filters] + async def _get_new_events(self) -> list[Event]: + from_block = self.last_served_block + 1 - self.lookback_distance + return await self.get_past_events(from_block, self._pending_block) + + async def get_past_events( + self, from_block: BlockNumber, to_block: BlockNumber + ) -> list[Event]: + log.debug(f"Fetching events in [{from_block}, {to_block}]") + log.debug(f"Using {len(self._partial_filters)} filters") events = [] - for event_filter in self.active_filters: - events.extend(event_filter.get_new_entries()) + for pf in self._partial_filters: + events.extend(await pf(from_block, to_block)) - messages, contract_upgrade_block = self.process_events(events) + messages, contract_upgrade_block = await self.process_events(events) if not contract_upgrade_block: return messages - log.info(f"Detected contract upgrade at block {contract_upgrade_block}, reinitializing") + log.info( + f"Detected contract upgrade at block {contract_upgrade_block}, reinitializing" + ) old_config = self._partial_filters, self.event_map, self.topic_map try: - rp.flush() + await rp.flush() self.__init__(self.bot) - self.start_tracking(BlockNumber(contract_upgrade_block + 1)) - messages.extend(self._get_new_events()) - return messages + await self.async_init() + return messages + await self.get_past_events( + contract_upgrade_block + 1, to_block + ) except Exception as err: # rollback to pre upgrade config if this goes wrong self._partial_filters, self.event_map, self.topic_map = old_config - self.active_filters.clear() raise err - def start_tracking(self, block: BlockNumber) -> None: - super().start_tracking(block) - self.active_filters.clear() - - def get_past_events(self, from_block: BlockNumber, to_block: BlockNumber) -> list[Event]: - events = [] - for pf in self._partial_filters: - events.extend(pf(from_block, to_block).get_all_entries()) - - messages, _ = self.process_events(events) - return messages - - def process_events(self, events: list[LogReceipt | EventData]) -> tuple[list[Event], Optional[BlockNumber]]: + async def process_events( + self, events: list[LogReceipt | EventData] + ) -> tuple[list[Event], BlockNumber | None]: events.sort(key=lambda e: (e.blockNumber, e.logIndex)) messages = [] upgrade_block = None log.debug(f"Aggregating {len(events)} events") - events: list[aDict] = self.aggregate_events(events) + events: list[aDict] = await self.aggregate_events(events) log.debug(f"Processing {len(events)} events") for event in events: @@ -224,18 +273,19 @@ def process_events(self, events: list[LogReceipt | EventData]) -> tuple[list[Eve log.debug(f"Checking event {event}") args_hash = hashlib.md5() - def hash_args(_args: aDict) -> None: + + def hash_args(_args: aDict, _hash=args_hash) -> None: for k, v in sorted(_args.items()): if not ("time" in k.lower() or "block" in k.lower()): - args_hash.update(f"{k}:{v}".encode()) + _hash.update(f"{k}:{v}".encode()) event_name, embed = None, None if (n := rp.get_name_by_address(event.address)) and "topics" in event: log.debug(f"Found event {event} for {n}") # default event path - contract = rp.get_contract_by_address(event.address) + contract = await rp.get_contract_by_address(event.address) contract_event = self.topic_map[event.topics[0].hex()] - topics = [w3.toHex(t) for t in event.topics] + topics = [w3.to_hex(t) for t in event.topics] _event = aDict(contract.events[contract_event]().process_log(event)) _event.topics = topics _event.args = aDict(_event.args) @@ -249,30 +299,45 @@ def hash_args(_args: aDict) -> None: event = _event if event_name := self.event_map.get(f"{n}.{event.event}"): - embed = self.handle_event(event_name, event) + embed = await self.handle_event(event_name, event) event_name = event.args.get("event_name", event_name) else: log.warning(f"Skipping unknown event {n}.{event.event}") - elif event.get("event") in self.event_map: event_name = self.event_map[event.event] - if event_name in ["contract_upgraded", "contract_added"]: + if event_name in [ + "odao_contract_upgraded_event", + "odao_contract_added_event", + ]: log.info("detected contract upgrade") upgrade_block = event.blockNumber + if event_name in [ + "odao_upgrade_pending_event", + "sdao_upgrade_vetoed_event", + "odao_contract_added_event", + "odao_contract_upgraded_event", + ]: + event.args = aDict(event.args) + hash_args(event.args) + embed = await self.handle_event(event_name, event) else: # deposit/exit event path event.args = aDict(event.args) hash_args(event.args) - embed = self.handle_global_event(event_name, event) + embed = await self.handle_global_event(event_name, event) event_name = event.args.get("event_name", event_name) if (event_name is None) or (embed is None): + log.debug(f"Skipping event {event}") continue # get the event offset based on the lowest event log index of events with the same txn hashes and block hashes identical_events = filter( - lambda e: (e.transactionHash == event.transactionHash) and (e.blockHash == event.blockHash), - events + lambda e: ( + (e.transactionHash == event.transactionHash) + and (e.blockHash == event.blockHash) + ), + events, ) tx_log_index = event.logIndex - min(e.logIndex for e in identical_events) @@ -283,13 +348,15 @@ def hash_args(_args: aDict) -> None: unique_id=f"{event.transactionHash.hex()}:{event_name}:{args_hash.hexdigest()}:{tx_log_index}", block_number=event.blockNumber, transaction_index=event.transactionIndex, - event_index=event.logIndex + event_index=event.logIndex, ) messages.append(response) return messages, upgrade_block - def aggregate_events(self, events: list[LogReceipt | EventData]) -> list[aDict]: + async def aggregate_events( + self, events: list[LogReceipt | EventData] + ) -> list[aDict]: # aggregate and deduplicate events within the same transaction events_by_tx = {} for event in reversed(events): @@ -301,10 +368,10 @@ def aggregate_events(self, events: list[LogReceipt | EventData]) -> list[aDict]: aggregation_attributes = { "rocketDepositPool.DepositAssigned": "assignment_count", - "unstETH.WithdrawalRequested": "amountOfStETH" + "unstETH.WithdrawalRequested": "amountOfStETH", } - def get_event_name(_event: LogReceipt | EventData) -> tuple[str, str]: + async def get_event_name(_event: LogReceipt | EventData) -> tuple[str, str]: if "topics" in _event: contract_name = rp.get_name_by_address(_event["address"]) name = self.topic_map[_event["topics"][0].hex()] @@ -322,29 +389,36 @@ def get_event_name(_event: LogReceipt | EventData) -> tuple[str, str]: events_by_name: dict[str, list[LogReceipt | EventData]] = {} for event in tx_events: - event_name, full_event_name = get_event_name(event) + event_name, full_event_name = await get_event_name(event) log.debug(f"Processing event {full_event_name}") if full_event_name not in events_by_name: events_by_name[full_event_name] = [] if full_event_name == "unstETH.WithdrawalRequested": - contract = rp.get_contract_by_address(event["address"]) - _event = aDict(contract.events[event_name]().processLog(event)) + contract = await rp.get_contract_by_address(event["address"]) + _event = aDict(contract.events[event_name]().process_log(event)) # sum up the amount of stETH withdrawn in this transaction if amount := tx_aggregates.get(full_event_name, 0): events.remove(event) - tx_aggregates[full_event_name] = amount + _event["args"]["amountOfStETH"] + tx_aggregates[full_event_name] = ( + amount + _event["args"]["amountOfStETH"] + ) elif full_event_name == "rocketTokenRETH.Transfer": - conflicting_events = ["rocketTokenRETH.TokensBurned", "rocketDepositPool.DepositReceived"] - if any((event in events_by_name for event in conflicting_events)): + conflicting_events = [ + "rocketTokenRETH.TokensBurned", + "rocketDepositPool.DepositReceived", + ] + if any(event in events_by_name for event in conflicting_events): events.remove(event) continue - if prev_event := tx_aggregates.get(full_event_name, None): + if prev_event := tx_aggregates.get(full_event_name): # only keep largest rETH transfer - contract = rp.get_contract_by_address(event["address"]) - _event = aDict(contract.events[event_name]().processLog(event)) - _prev_event = aDict(contract.events[event_name]().processLog(event)) + contract = await rp.get_contract_by_address(event["address"]) + _event = aDict(contract.events[event_name]().process_log(event)) + _prev_event = aDict( + contract.events[event_name]().process_log(event) + ) if _prev_event["args"]["value"] > _event["args"]["value"]: events.remove(event) event = prev_event @@ -355,17 +429,28 @@ def get_event_name(_event: LogReceipt | EventData) -> tuple[str, str]: if "MinipoolScrubbed" in events_by_name: events.remove(event) continue - elif full_event_name == "rocketDAOProtocolProposal.ProposalVoteOverridden": + elif ( + full_event_name + == "rocketDAOProtocolProposal.ProposalVoteOverridden" + ): # override is emitted first, thus only seen here after the main vote event # remove last seen vote event - vote_event = events_by_name.get("rocketDAOProtocolProposal.ProposalVoted", [None]).pop() + vote_event = events_by_name.get( + "rocketDAOProtocolProposal.ProposalVoted", [None] + ).pop() if vote_event is not None: events.remove(vote_event) elif full_event_name == "MinipoolPrestaked": - for assign_event in events_by_name.get("rocketDepositPool.DepositAssigned", []).copy(): - assigned_minipool = w3.to_checksum_address(assign_event["topics"][1][-20:]) + for assign_event in events_by_name.get( + "rocketDepositPool.DepositAssigned", [] + ).copy(): + assigned_minipool = w3.to_checksum_address( + assign_event["topics"][1][-20:] + ) if event["address"] == assigned_minipool: - events_by_name["rocketDepositPool.DepositAssigned"].remove(assign_event) + events_by_name["rocketDepositPool.DepositAssigned"].remove( + assign_event + ) events.remove(assign_event) tx_aggregates["rocketDepositPool.DepositAssigned"] -= 1 elif full_event_name in aggregation_attributes: @@ -375,35 +460,51 @@ def get_event_name(_event: LogReceipt | EventData) -> tuple[str, str]: tx_aggregates[full_event_name] = count + 1 else: # count, but report as individual events - tx_aggregates[full_event_name] = tx_aggregates.get(full_event_name, 0) + 1 + tx_aggregates[full_event_name] = ( + tx_aggregates.get(full_event_name, 0) + 1 + ) if event in events: events_by_name[full_event_name].append(event) events = [aDict(event) for event in events] for event in events: - _, full_event_name = get_event_name(event) + _, full_event_name = await get_event_name(event) if full_event_name not in aggregation_attributes: continue tx_hash = event["transactionHash"] - if (aggregated_value := aggregates[tx_hash].get(full_event_name, None)) is None: + if ( + aggregated_value := aggregates[tx_hash].get(full_event_name, None) + ) is None: continue event[aggregation_attributes[full_event_name]] = aggregated_value return events - def handle_global_event(self, event_name: str, event: aDict) -> Optional[Embed]: - receipt = w3.eth.get_transaction_receipt(event.transactionHash) - if not any([ - rp.call("rocketMinipoolManager.getMinipoolExists", receipt.to), - rp.call("rocketMinipoolManager.getMinipoolExists", event.address), - rp.get_name_by_address(receipt.to), - rp.get_name_by_address(event.address) - ]): + async def handle_global_event(self, event_name: str, event: aDict) -> Embed | None: + receipt = await w3.eth.get_transaction_receipt(event.transactionHash) + + is_minipool_event = await rp.is_minipool(event.address) or await rp.is_minipool( + receipt.to + ) + is_megapool_event = await rp.is_megapool(event.address) or await rp.is_megapool( + receipt.to + ) + + if not any( + [ + is_minipool_event, + is_megapool_event, + rp.get_name_by_address(receipt.to) not in [None, "multicall3"], + rp.get_name_by_address(event.address), + ] + ): # some random contract we don't care about - log.warning(f"Skipping {event.transactionHash.hex()} because the called contract is not a minipool") + log.warning( + f"Skipping {event.transactionHash.hex()} because the called contract is not a minipool" + ) return None pubkey = None @@ -414,14 +515,18 @@ def handle_global_event(self, event_name: str, event: aDict) -> Optional[Embed]: # maybe the contract has it stored? if not pubkey: - pubkey = rp.call("rocketMinipoolManager.getMinipoolPubkey", event.address).hex() + pubkey = ( + await rp.call("rocketMinipoolManager.getMinipoolPubkey", event.address) + ).hex() # maybe it's in the transaction? if not pubkey: with warnings.catch_warnings(): warnings.simplefilter("ignore") - deposit_contract = rp.get_contract_by_name("casperDeposit") - processed_logs = deposit_contract.events.DepositEvent().processReceipt(receipt) + deposit_contract = await rp.get_contract_by_name("casperDeposit") + processed_logs = deposit_contract.events.DepositEvent().process_receipt( + receipt + ) # attempt to retrieve the pubkey if processed_logs: @@ -433,89 +538,128 @@ def handle_global_event(self, event_name: str, event: aDict) -> Optional[Embed]: # while we are at it add the sender address, so it shows up event.args["from"] = receipt["from"] - if (n := rp.get_name_by_address(receipt["to"])) is None or not n.startswith("rocket"): + if (n := rp.get_name_by_address(receipt["to"])) is None or not n.startswith( + "rocket" + ): event.args["from"] = receipt["to"] event.args["caller"] = receipt["from"] - # and add the minipool address, which is the origin of the event - event.args.minipool = event.address + if is_minipool_event: + # and add the minipool address, which is the origin of the event + event.args.minipool = event.address + if is_megapool_event: + event.args.megapool = event.address + event.args.node = await rp.call( + "rocketMegapoolDelegate.getNodeAddress", address=event.address + ) - return self.handle_event(event_name, event) + return await self.handle_event(event_name, event) - @staticmethod - def handle_event(event_name: str, event: aDict) -> Optional[Embed]: + async def handle_event(self, event_name: str, event: aDict) -> Embed | None: args = aDict(event.args) if "negative_rETH_ratio_update_event" in event_name: - args.currRETHRate = solidity.to_float(args.totalEth) / solidity.to_float(args.rethSupply) if args.rethSupply > 0 else 1 - args.prevRETHRate = solidity.to_float(rp.call("rocketTokenRETH.getExchangeRate", block=event.blockNumber - 1)) + args.currRETHRate = ( + solidity.to_float(args.totalEth) / solidity.to_float(args.rethSupply) + if args.rethSupply > 0 + else 1 + ) + args.prevRETHRate = solidity.to_float( + await rp.call( + "rocketTokenRETH.getExchangeRate", block=event.blockNumber - 1 + ) + ) d = args.currRETHRate - args.prevRETHRate if d > 0 or abs(d) < 0.00001: return None - - if "price_update_event" in event_name: + elif "price_update_event" in event_name: args.value = args.rplPrice - next_period = rp.call("rocketRewardsPool.getClaimIntervalTimeStart", block=event.blockNumber) + rp.call("rocketRewardsPool.getClaimIntervalTime", block=event.blockNumber) - args.rewardPeriodEnd = next_period - update_rate = rp.call("rocketDAOProtocolSettingsNetwork.getSubmitPricesFrequency", block=event.blockNumber) # in seconds + period_start = await rp.call( + "rocketRewardsPool.getClaimIntervalTimeStart", block=event.blockNumber + ) + period_length = await rp.call( + "rocketRewardsPool.getClaimIntervalTime", block=event.blockNumber + ) + args.rewardPeriodEnd = period_start + period_length + # in seconds + update_rate = await rp.call( + "rocketDAOProtocolSettingsNetwork.getSubmitPricesFrequency", + block=event.blockNumber, + ) # get timestamp of event block - ts = block_to_ts(event.blockNumber) + ts = await block_to_ts(event.blockNumber) # check if the next update is after the next period ts earliest_next_update = ts + update_rate # if it will update before the next period, skip - if not (ts < next_period < earliest_next_update): + if not (ts < args.rewardPeriodEnd < earliest_next_update): return None - - if event_name == "bootstrap_pdao_setting_multi_event": + elif event_name == "bootstrap_pdao_setting_multi_event": description_parts = [] for i in range(len(args.settingContractNames)): value_raw = args.values[i] match args.types[i]: case 0: # SettingType.UINT256 - value = w3.toInt(value_raw) + value = w3.to_int(value_raw) case 1: # SettingType.BOOL value = bool(value_raw) case 2: # SettingType.ADDRESS - value = w3.toChecksumAddress(value_raw) + value = w3.to_checksum_address(value_raw) case _: value = "???" - description_parts.append( - f"`{args.settingPaths[i]}` set to `{value}`" - ) + description_parts.append(f"`{args.settingPaths[i]}` set to `{value}`") args.description = "\n".join(description_parts) elif event_name == "bootstrap_pdao_claimer_event": + def share_repr(percentage: float) -> str: max_width = 35 num_points = round(max_width * percentage / 100) - return '*' * num_points - - node_share = args.nodePercent / 10 ** 16 - pdao_share = args.protocolPercent / 10 ** 16 - odao_share = args.trustedNodePercent / 10 ** 16 - - args.description = '\n'.join([ - "Node Operator Share", - f"{share_repr(node_share)} {node_share:.1f}%", - "Protocol DAO Share", - f"{share_repr(pdao_share)} {pdao_share:.1f}%", - "Oracle DAO Share", - f"{share_repr(odao_share)} {odao_share:.1f}%", - ]) + return "*" * num_points + + node_share = args.nodePercent / 10**16 + pdao_share = args.protocolPercent / 10**16 + odao_share = args.trustedNodePercent / 10**16 + + args.description = "\n".join( + [ + "Node Operator Share", + f"{share_repr(node_share)} {node_share:.1f}%", + "Protocol DAO Share", + f"{share_repr(pdao_share)} {pdao_share:.1f}%", + "Oracle DAO Share", + f"{share_repr(odao_share)} {odao_share:.1f}%", + ] + ) elif event_name == "bootstrap_sdao_member_kick_event": - args.memberAddress = el_explorer_url(args.memberAddress, block=(event.blockNumber - 1)) + args.memberAddress = await el_explorer_url( + args.memberAddress, block=(event.blockNumber - 1) + ) elif event_name in [ "odao_member_leave_event", "odao_member_kick_event", "sdao_member_leave_event", - "sdao_member_request_leave_event" + "sdao_member_request_leave_event", ]: - args.nodeAddress = el_explorer_url(args.nodeAddress, block=(event.blockNumber - 1)) - elif event_name.startswith("cs_deposit") or event_name.startswith("cs_withdraw"): + args.nodeAddress = await el_explorer_url( + args.nodeAddress, block=(event.blockNumber - 1) + ) + elif any( + [ + event_name.startswith("cs_deposit"), + event_name.startswith("cs_withdraw"), + event_name.startswith("rocksolid_deposit"), + ] + ): args.assets = solidity.to_float(args.assets) args.shares = solidity.to_float(args.shares) + elif event_name.startswith("rocksolid_withdraw"): + assets = await rp.call( + "RockSolidVault.convertToAssets", args.shares, block=event.blockNumber + ) + args.assets = solidity.to_float(assets) + args.shares = solidity.to_float(args.shares) elif event_name == "cs_max_validator_change_event": args.oldLimit, args.newLimit = args.oldValue, args.newValue if args.newLimit > args.oldLimit: @@ -523,42 +667,51 @@ def share_repr(percentage: float) -> str: elif args.newLimit < args.oldLimit: event_name = event_name.replace("change", "decrease") elif event_name == "cs_operator_added_event": - args.address = w3.eth.get_transaction_receipt(event.transactionHash)["from"] + args.address = await w3.eth.get_transaction_receipt(event.transactionHash)[ + "from" + ] elif event_name == "cs_rpl_treasury_fee_change_event": args.oldFee = 100 * solidity.to_float(args.oldFee) args.newFee = 100 * solidity.to_float(args.newFee) elif "event_name" in [ "cs_eth_treasury_fee_change_event", "cs_eth_no_fee_change_event", - "cs_eth_mint_fee_change_event" + "cs_eth_mint_fee_change_event", ]: args.oldFee = 100 * solidity.to_float(args.oldValue) args.newFee = 100 * solidity.to_float(args.newValue) elif event_name.startswith("cs_operators"): - args.operatorList = "\n".join([el_explorer_url(address) for address in args.operators]) - elif event_name in ["cs_rpl_min_ratio_change_event", "cs_rpl_target_ratio_change_event"]: + args.operatorList = "\n".join( + [await el_explorer_url(address) for address in args.operators] + ) + elif event_name in [ + "cs_rpl_min_ratio_change_event", + "cs_rpl_target_ratio_change_event", + ]: args.oldRatio = 100 * solidity.to_float(args.oldRatio) args.newRatio = 100 * solidity.to_float(args.newRatio) if "submission" in args: - args.submission = aDict(dict(zip(SUBMISSION_KEYS, args.submission))) + args.submission = aDict( + dict(zip(SUBMISSION_KEYS, args.submission, strict=False)) + ) if "otc_swap" in event_name: # signer = seller # sender = buyer # either the selling or buying token has to be the RPL token - rpl = rp.get_address_by_name("rocketTokenRPL") + rpl = await rp.get_address_by_name("rocketTokenRPL") if args.signerToken != rpl and args.senderToken != rpl: return None - args.seller = w3.toChecksumAddress(f"0x{event.topics[2][-40:]}") - args.buyer = w3.toChecksumAddress(f"0x{event.topics[3][-40:]}") + args.seller = w3.to_checksum_address(f"0x{event.topics[2][-40:]}") + args.buyer = w3.to_checksum_address(f"0x{event.topics[3][-40:]}") # token names - s = rp.assemble_contract(name="ERC20", address=args.signerToken) - args.sellToken = s.functions.symbol().call() - sell_decimals = s.functions.decimals().call() - b = rp.assemble_contract(name="ERC20", address=args.senderToken) - args.buyToken = b.functions.symbol().call() - buy_decimals = b.functions.decimals().call() + s = await rp.assemble_contract(name="ERC20", address=args.signerToken) + args.sellToken = await s.functions.symbol().call() + sell_decimals = await s.functions.decimals().call() + b = await rp.assemble_contract(name="ERC20", address=args.senderToken) + args.buyToken = await b.functions.symbol().call() + buy_decimals = await b.functions.decimals().call() # token amounts args.sellAmount = solidity.to_float(args.signerAmount, sell_decimals) args.buyAmount = solidity.to_float(args.senderAmount, buy_decimals) @@ -571,42 +724,77 @@ def share_repr(percentage: float) -> str: args.otherToken = args.sellToken if args.otherToken.lower() == "wETH": # get exchange rate from rp - args.marketExchangeRate = rp.call("rocketNetworkPrices.getRPLPrice") + args.marketExchangeRate = await rp.call( + "rocketNetworkPrices.getRPLPrice" + ) # calculate the discount received compared to the market price - args.discountAmount = (1 - args.exchangeRate / solidity.to_float(args.marketExchangeRate)) * 100 + args.discountAmount = ( + 1 - args.exchangeRate / solidity.to_float(args.marketExchangeRate) + ) * 100 receipt = None - if cfg["rocketpool.chain"] == "mainnet": - receipt = w3.eth.get_transaction_receipt(event.transactionHash) - args.tnx_fee = solidity.to_float(receipt["gasUsed"] * receipt["effectiveGasPrice"]) - args.tnx_fee_usd = round(rp.get_eth_usdc_price() * args.tnx_fee, 2) + if cfg.rocketpool.chain == "mainnet": + receipt = await w3.eth.get_transaction_receipt(event.transactionHash) + args.tnx_fee = receipt["gasUsed"] * receipt["effectiveGasPrice"] + args.tnx_fee_usd = round( + await rp.get_eth_usdc_price() * args.tnx_fee / 10**18, 2 + ) args.caller = receipt["from"] # add transaction hash and block number to args - args.transactionHash = event.transactionHash.hex() + args.transactionHash = event.transactionHash.to_0x_hex() args.blockNumber = event.blockNumber # add proposal message manually if the event contains a proposal if "pdao_proposal" in event_name: - proposal_id = event.args.proposalID if "proposalID" in event.args else event.args.proposalId + proposal_id = ( + event.args.proposalID + if "proposalID" in event.args + else event.args.proposalId + ) if "root" in event_name: # not interesting if the root wasn't submitted in response to a challenge # ChallengeState.Challenged = 1 - challenge_state = rp.call("rocketDAOProtocolVerifier.getChallengeState", proposal_id, args.index, block=event.blockNumber) + challenge_state = await rp.call( + "rocketDAOProtocolVerifier.getChallengeState", + proposal_id, + args.index, + block=event.blockNumber, + ) if challenge_state != 1: return None if "add" in event_name or "destroy" in event_name: - args.proposalBond = solidity.to_int(rp.call("rocketDAOProtocolVerifier.getProposalBond", proposal_id)) + args.proposalBond = solidity.to_int( + await rp.call( + "rocketDAOProtocolVerifier.getProposalBond", proposal_id + ) + ) elif "root" in event_name or "challenge" in event_name: - args.proposalBond = solidity.to_int(rp.call("rocketDAOProtocolVerifier.getProposalBond", proposal_id)) - args.challengeBond = solidity.to_int(rp.call("rocketDAOProtocolVerifier.getChallengeBond", proposal_id)) - args.challengePeriod = rp.call("rocketDAOProtocolVerifier.getChallengePeriod", proposal_id) + args.proposalBond = solidity.to_int( + await rp.call( + "rocketDAOProtocolVerifier.getProposalBond", proposal_id + ) + ) + args.challengeBond = solidity.to_int( + await rp.call( + "rocketDAOProtocolVerifier.getChallengeBond", proposal_id + ) + ) + args.challengePeriod = await rp.call( + "rocketDAOProtocolVerifier.getChallengePeriod", proposal_id + ) # create human-readable decision for votes if "direction" in args: - args.decision = ["invalid", "abstain", "for", "against", "against with veto"][args.direction] + args.decision = [ + "invalid", + "abstain", + "for", + "against", + "against with veto", + ][args.direction] if "votingPower" in args: args.votingPower = solidity.to_float(args.votingPower) @@ -614,19 +802,28 @@ def share_repr(percentage: float) -> str: # not interesting return None elif "vote_override" in event_name: - proposal_block = rp.call("rocketDAOProtocolProposal.getProposalBlock", proposal_id) - args.votingPower = solidity.to_float(rp.call("rocketNetworkVoting.getVotingPower", args.voter, proposal_block)) + proposal_block = await rp.call( + "rocketDAOProtocolProposal.getProposalBlock", proposal_id + ) + args.votingPower = solidity.to_float( + await rp.call( + "rocketNetworkVoting.getVotingPower", args.voter, proposal_block + ) + ) if args.votingPower < 100: # not interesting return None dao = ProtocolDAO() - proposal = dao.fetch_proposal(proposal_id) + proposal = await dao.fetch_proposal(proposal_id) args.proposal_body = dao.build_proposal_body( proposal, include_proposer=False, include_payload=("add" in event_name), - include_votes=all(kw not in event_name for kw in ("add", "challenge", "root", "destroy")), + include_votes=all( + kw not in event_name + for kw in ("add", "challenge", "root", "destroy") + ), ) elif "dao_proposal" in event_name: proposal_id = event.args.proposalID @@ -636,63 +833,87 @@ def share_repr(percentage: float) -> str: args.decision = "for" if args.supported else "against" # change prefix for DAO-specific event - dao_name = rp.call("rocketDAOProposal.getDAO", proposal_id) - event_name = event_name.replace("dao", { - "rocketDAONodeTrustedProposals": "odao", - "rocketDAOSecurityProposals": "sdao" - }[dao_name]) + dao_name = await rp.call("rocketDAOProposal.getDAO", proposal_id) + event_name = event_name.replace( + "dao", + { + "rocketDAONodeTrustedProposals": "odao", + "rocketDAOSecurityProposals": "sdao", + }[dao_name], + ) dao = DefaultDAO(dao_name) - proposal = dao.fetch_proposal(proposal_id) + proposal = await dao.fetch_proposal(proposal_id) args.proposal_body = dao.build_proposal_body( proposal, include_proposer=False, include_payload=("add" in event_name), include_votes=("add" not in event_name), ) - # add inflation and new supply if inflation occurred - if "rpl_inflation" in event_name: - args.total_supply = int(solidity.to_float(rp.call("rocketTokenRPL.totalSupply"))) - args.inflation = round(rp.get_annual_rpl_inflation() * 100, 4) - - if "auction_bid_event" in event_name: + elif "rpl_inflation" in event_name: + args.total_supply = int( + solidity.to_float(await rp.call("rocketTokenRPL.totalSupply")) + ) + args.inflation = round(await rp.get_annual_rpl_inflation() * 100, 4) + elif "auction_bid_event" in event_name: eth = solidity.to_float(args.bidAmount) price = solidity.to_float( - rp.call("rocketAuctionManager.getLotPriceAtBlock", args.lotIndex, args.blockNumber)) + await rp.call( + "rocketAuctionManager.getLotPriceAtBlock", + args.lotIndex, + args.blockNumber, + ) + ) args.rplAmount = eth / price - if event_name in ["rpl_stake_event", "rpl_withdraw_event"]: # get eth price by multiplying the amount by the current RPL ratio - rpl_ratio = solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice")) + rpl_ratio = solidity.to_float( + await rp.call("rocketNetworkPrices.getRPLPrice") + ) args.amount = solidity.to_float(args.amount) args.ethAmount = args.amount * rpl_ratio - if event_name in ["node_merkle_rewards_claimed"]: - rpl_ratio = solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice")) - args.amountRPL = sum(solidity.to_float(r) for r in args.amountRPL) - args.amountETH = sum(solidity.to_float(e) for e in args.amountETH) - args.ethAmount = args.amountRPL * rpl_ratio - if "transfer_event" in event_name: + elif event_name in ["node_merkle_rewards_claimed"]: + return None # TODO + elif "transfer_event" in event_name: token_prefix = event_name.split("_", 1)[0] args.amount = args.value / 10**18 - if args["from"] in cfg["rocketpool.dao_multsigs"]: + if args["from"] in cfg.rocketpool.dao_multisigs: event_name = "pdao_erc20_transfer_event" - token_contract = rp.assemble_contract(name="ERC20", address=event["address"]) - args.symbol = token_contract.functions.symbol().call() + token_contract = await rp.assemble_contract( + name="ERC20", address=event["address"] + ) + args.symbol = await token_contract.functions.symbol().call() elif token_prefix != "reth": return None + elif event_name == "reth_burn_event": + # filter small burns < 1 rETH + if solidity.to_float(args.amount) < 1: + return None + elif event_name == "validator_multi_deposit_event": + args.amount = args.totalBond + if args.numberOfValidators == 1: + event_name = "validator_deposit_event" # reject if the amount is not major - if any([event_name == "reth_transfer_event" and args.amount < 1000, + if any( + [ + event_name == "reth_transfer_event" and args.amount < 1000, event_name == "rpl_stake_event" and args.amount < 1000, event_name == "rpl_stake_event" and args.amount < 1000, - event_name == "node_merkle_rewards_claimed" and args.ethAmount < 5 and args.amountETH < 5, - event_name == "rpl_withdraw_event" and args.ethAmount < 16]): + event_name == "node_merkle_rewards_claimed" + and args.ethAmount < 5 + and args.amountETH < 5, + event_name == "rpl_withdraw_event" and args.ethAmount < 16, + ] + ): amounts = {} for arg in ["ethAmount", "amount", "amountETH"]: if arg in args: amounts[arg] = args[arg] - log.debug(f"Skipping {event_name} because the event ({amounts}) is too small to be interesting") + log.debug( + f"Skipping {event_name} because the event ({amounts}) is too small to be interesting" + ) return None if "claimingContract" in args and args.claimingAddress == args.claimingContract: @@ -704,30 +925,73 @@ def share_repr(percentage: float) -> str: # loop over all possible contracts if we get a match return empty response for contract in possible_contracts: - if rp.get_address_by_name(contract) == args.claimingContract: + if await rp.get_address_by_name(contract) == args.claimingContract: return None + if event_name == "odao_upgrade_pending_event": + args.contractName = await rp.call( + "rocketDAONodeTrustedUpgrade.getName", + args.upgradeProposalID, + block=event.blockNumber, + ) + args.contractAddress = await rp.call( + "rocketDAONodeTrustedUpgrade.getUpgradeAddress", + args.upgradeProposalID, + block=event.blockNumber, + ) + args.vetoDeadline = await rp.call( + "rocketDAONodeTrustedUpgrade.getEnd", + args.upgradeProposalID, + block=event.blockNumber, + ) + if args.contractAddress == ADDRESS_ZERO: + del args.contractAddress + event_name = "upgrade_pending_abi_event" + elif event_name == "sdao_upgrade_vetoed_event": + args.contractName = await rp.call( + "rocketDAONodeTrustedUpgrade.getName", + args.upgradeProposalID, + block=event.blockNumber, + ) + elif event_name == "odao_contract_upgraded_event": + args.contractName = rp.get_name_by_address(args.oldAddress) or "Unknown" + elif event_name == "odao_contract_added_event": + args.contractName = rp.get_name_by_address(args.newAddress) or "Unknown" if "node_register_event" in event_name: - args.timezone = rp.call("rocketNodeManager.getNodeTimezoneLocation", args.node) + args.timezone = await rp.call( + "rocketNodeManager.getNodeTimezoneLocation", args.node + ) if "odao_member_challenge_event" in event_name: - args.challengeDeadline = args.time + rp.call("rocketDAONodeTrustedSettingsMembers.getChallengeWindow") + args.challengeDeadline = args.time + await rp.call( + "rocketDAONodeTrustedSettingsMembers.getChallengeWindow" + ) if "odao_member_challenge_decision_event" in event_name: if args.success: event_name = "odao_member_challenge_accepted_event" # get their RPL bond that was burned by querying the previous block args.rplBondAmount = solidity.to_float( - rp.call( + await rp.call( "rocketDAONodeTrusted.getMemberRPLBondAmount", args.nodeChallengedAddress, - block=args.blockNumber - 1 + block=args.blockNumber - 1, ) ) args.sender = args.nodeChallengeDeciderAddress else: event_name = "odao_member_challenge_rejected_event" if "node_smoothing_pool_state_changed" in event_name: - # geet minipool count - args.minipoolCount = rp.call("rocketMinipoolManager.getNodeMinipoolCount", args.node) + validator_count = await rp.call( + "rocketMinipoolManager.getNodeMinipoolCount", args.node + ) + megapool_address = await rp.call( + "rocketNodeManager.getMegapoolAddress", args.node + ) + if megapool_address != ADDRESS_ZERO: + validator_count += await rp.call( + "rocketMegapoolDelegate.getActiveValidatorCount", + address=megapool_address, + ) + args.validatorCount = validator_count if args.state: event_name = "node_smoothing_pool_joined" else: @@ -739,17 +1003,27 @@ def share_repr(percentage: float) -> str: event_name = "node_merkle_rewards_claimed_rpl" if "minipool_deposit_received_event" in event_name: - contract = rp.assemble_contract("rocketMinipoolDelegate", args.minipool) - args.commission = solidity.to_float(contract.functions.getNodeFee().call()) + contract = await rp.assemble_contract( + "rocketMinipoolDelegate", args.minipool + ) + args.commission = solidity.to_float( + await contract.functions.getNodeFee().call() + ) # get the transaction receipt - args.depositAmount = rp.call("rocketMinipool.getNodeDepositBalance", address=args.minipool, block=args.blockNumber) + args.depositAmount = await rp.call( + "rocketMinipool.getNodeDepositBalance", + address=args.minipool, + block=args.blockNumber, + ) user_deposit = args.depositAmount - receipt = w3.eth.get_transaction_receipt(args.transactionHash) + receipt = await w3.eth.get_transaction_receipt(args.transactionHash) args.node = receipt["from"] - ee = rp.get_contract_by_name("rocketNodeDeposit").events.DepositReceived() + ee = ( + await rp.get_contract_by_name("rocketNodeDeposit") + ).events.DepositReceived() with warnings.catch_warnings(): warnings.simplefilter("ignore") - processed_logs = ee.processReceipt(receipt) + processed_logs = ee.process_receipt(receipt) for deposit_event in processed_logs: # needs to be within 5 before the event if event.logIndex - 6 < deposit_event.logIndex < event.logIndex: @@ -757,20 +1031,26 @@ def share_repr(percentage: float) -> str: if user_deposit < args.depositAmount: args.creditAmount = args.depositAmount - user_deposit args.balanceAmount = 0 - e = rp.get_contract_by_name("rocketVault").events.EtherWithdrawn() + e = ( + await rp.get_contract_by_name("rocketVault") + ).events.EtherWithdrawn() with warnings.catch_warnings(): warnings.simplefilter("ignore") - processed_logs = e.processReceipt(receipt) + processed_logs = e.process_receipt(receipt) - deposit_contract = bytes(w3.soliditySha3(["string"], ["rocketNodeDeposit"])) + deposit_contract = bytes( + w3.solidity_keccak(["string"], ["rocketNodeDeposit"]) + ) for withdraw_event in processed_logs: # event.logindex 44, withdraw_event.logindex 50, rough distance like that # reminder order is different than the previous example - if event.logIndex - 7 < withdraw_event.logIndex < event.logIndex: - if withdraw_event.args["by"] == deposit_contract: - args.balanceAmount = withdraw_event.args["amount"] - args.creditAmount -= args.balanceAmount - break + if ( + event.logIndex - 7 < withdraw_event.logIndex < event.logIndex + and withdraw_event.args["by"] == deposit_contract + ): + args.balanceAmount = withdraw_event.args["amount"] + args.creditAmount -= args.balanceAmount + break if args.balanceAmount == 0: event_name += "_credit" @@ -785,16 +1065,29 @@ def share_repr(percentage: float) -> str: case _: return None - if event_name in ["minipool_bond_reduce_event", "minipool_vacancy_prepared_event", - "minipool_withdrawal_processed_event", "minipool_bond_reduction_started_event", - "pool_deposit_assigned_event"]: + args.operator = await rp.call( + "rocketMinipoolDelegate.getNodeAddress", address=args.minipool + ) + + if event_name in [ + "minipool_bond_reduce_event", + "minipool_vacancy_prepared_event", + "minipool_withdrawal_processed_event", + "minipool_bond_reduction_started_event", + "pool_deposit_assigned_event", + ]: # get the node operator address from minipool contract - contract = rp.assemble_contract("rocketMinipool", args.minipool) - args.node = contract.functions.getNodeAddress().call() + contract = await rp.assemble_contract("rocketMinipool", args.minipool) + args.node = await contract.functions.getNodeAddress().call() if "minipool_bond_reduction_started_event" in event_name: # get the previousBondAmount from the minipool contract args.previousBondAmount = solidity.to_float( - rp.call("rocketMinipool.getNodeDepositBalance", address=args.minipool, block=args.blockNumber - 1)) + await rp.call( + "rocketMinipool.getNodeDepositBalance", + address=args.minipool, + block=args.blockNumber - 1, + ) + ) elif event_name == "minipool_withdrawal_processed_event": args.totalAmount = args.nodeAmount + args.userAmount elif event_name == "pool_deposit_assigned_event": @@ -804,38 +1097,63 @@ def share_repr(percentage: float) -> str: args.assignmentCount = event["assignment_count"] else: return None - elif "minipool_scrub" in event_name and rp.call("rocketMinipoolDelegate.getVacant", address=args.minipool): + elif "minipool_scrub" in event_name and await rp.call( + "rocketMinipoolDelegate.getVacant", address=args.minipool + ): event_name = f"vacant_{event_name}" if event_name == "vacant_minipool_scrub_event": # let's try to determine the reason. there are 4 reasons a vacant minipool can get scrubbed: - # 1. the validator does not have the withdrawal credentials set to the minipool address, but to some other address + # 1. the validator does not have the withdrawal credentials set to the minipool address, + # but to some other address # 2. the validator balance on the beacon chain is lower than configured in the minipool contract # 3. the validator does not have the active_ongoing validator status - # 4. the migration could have timed out, the oDAO will scrub minipools after they have passed half of the migration window + # 4. the migration could have timed out, the oDAO will scrub minipools + # after they have passed half of the migration window # get pubkey from minipool contract - pubkey = rp.call("rocketMinipoolManager.getMinipoolPubkey", args.minipool).hex() - vali_info = bacon.get_validator(f"0x{pubkey}")["data"] + pubkey = ( + await rp.call( + "rocketMinipoolManager.getMinipoolPubkey", args.minipool + ) + ).hex() + vali_info = (await bacon.get_validator(f"0x{pubkey}"))["data"] reason = "joe fucking up (unknown reason)" if vali_info: # check for #1 - if all([vali_info["validator"]["withdrawal_credentials"][:4] == "0x01", - vali_info["validator"]["withdrawal_credentials"][-40:] != args.minipool[2:]]): + if all( + [ + vali_info["validator"]["withdrawal_credentials"][:4] + == "0x01", + vali_info["validator"]["withdrawal_credentials"][-40:] + != args.minipool[2:], + ] + ): reason = "having invalid withdrawal credentials set on the beacon chain" # check for #2 configured_balance = solidity.to_float( - rp.call("rocketMinipoolDelegate.getPreMigrationBalance", address=args.minipool, - block=args.blockNumber - 1)) - if (solidity.to_float(vali_info["balance"], 9) - configured_balance) < -0.01: + await rp.call( + "rocketMinipoolDelegate.getPreMigrationBalance", + address=args.minipool, + block=args.blockNumber - 1, + ) + ) + if ( + solidity.to_float(vali_info["balance"], 9) - configured_balance + ) < -0.01: reason = "having a balance lower than configured in the minipool contract on the beacon chain" # check for #3 if vali_info["status"] != "active_ongoing": reason = "not being active on the beacon chain" # check for #4 - scrub_period = rp.call("rocketDAONodeTrustedSettingsMinipool.getPromotionScrubPeriod", - block=args.blockNumber - 1) - minipool_creation = rp.call("rocketMinipoolDelegate.getStatusTime", address=args.minipool, - block=args.blockNumber - 1) - block_time = block_to_ts(args.blockNumber - 1) + scrub_period = await rp.call( + "rocketDAONodeTrustedSettingsMinipool.getPromotionScrubPeriod", + block=args.blockNumber - 1, + ) + minipool_creation = await rp.call( + "rocketMinipoolDelegate.getStatusTime", + address=args.minipool, + block=args.blockNumber - 1, + ) + block_time = await block_to_ts(args.blockNumber - 1) if block_time - minipool_creation > scrub_period // 2: reason = "taking too long to migrate their withdrawal credentials on the beacon chain" args.scrub_reason = reason @@ -844,12 +1162,15 @@ def share_repr(percentage: float) -> str: if solidity.to_float(args.amountOfStETH) < 10_000: return None if receipt: - args.timestamp = block_to_ts(receipt["blockNumber"]) + args.timestamp = await block_to_ts(receipt["blockNumber"]) args.event_name = event_name - args = prepare_args(args) + args = await prepare_args(args) event.args = args - return assemble(args) + return await assemble(args) + async def setup(bot): - await bot.add_cog(Events(bot)) + cog = Events(bot) + await cog.async_init() + await bot.add_cog(cog) diff --git a/rocketwatch/plugins/fee_distribution/fee_distribution.py b/rocketwatch/plugins/fee_distribution/fee_distribution.py new file mode 100644 index 00000000..42ae9851 --- /dev/null +++ b/rocketwatch/plugins/fee_distribution/fee_distribution.py @@ -0,0 +1,112 @@ +import logging +from io import BytesIO +from typing import Literal + +from discord import File, Interaction +from discord.app_commands import command +from discord.ext import commands +from matplotlib import pyplot as plt +from matplotlib.figure import Figure + +from rocketwatch import RocketWatch +from utils.embeds import Embed +from utils.readable import render_tree_legacy +from utils.visibility import is_hidden + +log = logging.getLogger("rocketwatch.fee_distribution") + + +class FeeDistribution(commands.Cog): + def __init__(self, bot: RocketWatch): + self.bot = bot + + async def _get_minipools(self, bond: int) -> list[dict]: + result = await self.bot.db.minipools.aggregate( + [ + { + "$match": { + "node_deposit_balance": bond, + "beacon.status": "active_ongoing", + } + }, + { + "$group": { + "_id": {"$round": ["$node_fee", 2]}, + "count": {"$sum": 1}, + } + }, + {"$sort": {"_id": 1}}, + ] + ) + return await result.to_list() + + async def _get_tree(self) -> dict: + tree = {} + for bond in (8, 16): + subtree = {} + for entry in await self._get_minipools(bond): + fee_percentage = entry["_id"] * 100 + subtree[f"{fee_percentage:.0f}%"] = entry["count"] + tree[f"{bond} ETH"] = subtree + return tree + + async def _get_pie(self) -> Figure: + fig, axs = plt.subplots(1, 2) + for i, bond in enumerate((8, 16)): + labels = [] + sizes = [] + + for entry in await self._get_minipools(bond): + fee_percentage = entry["_id"] * 100 + labels.append(f"{fee_percentage:.0f}%") + sizes.append(entry["count"]) + + total = sum(sizes) + # avoid overlapping labels for small slices + for j in range(len(sizes)): + if sizes[j] < 0.05 * total: + labels[j] = "" + + ax = axs[i] + ax.set_title(f"{bond} ETH") + ax.pie( + sizes, + labels=labels, + autopct=lambda p, _total=total: ( + f"{p * _total / 100:.0f}" if (p >= 5) else "" + ), + ) + return fig + + @command() + async def fee_distribution( + self, interaction: Interaction, mode: Literal["tree", "pie"] = "pie" + ): + """ + Show the distribution of minipool commission percentages. + """ + await interaction.response.defer(ephemeral=is_hidden(interaction)) + + e = Embed() + e.title = "Minipool Fee Distribution" + + if mode == "tree": + tree = await self._get_tree() + e.description = f"```\n{render_tree_legacy(tree, 'Minipools')}\n```" + await interaction.followup.send(embed=e) + elif mode == "pie": + img = BytesIO() + fig = await self._get_pie() + fig.tight_layout() + fig.savefig(img, format="png") + img.seek(0) + fig.clear() + plt.close() + + file_name = "fee_distribution.png" + e.set_image(url=f"attachment://{file_name}") + await interaction.followup.send(embed=e, file=File(img, filename=file_name)) + + +async def setup(bot): + await bot.add_cog(FeeDistribution(bot)) diff --git a/rocketwatch/plugins/forum/forum.py b/rocketwatch/plugins/forum/forum.py index 91a0043b..e29c966b 100644 --- a/rocketwatch/plugins/forum/forum.py +++ b/rocketwatch/plugins/forum/forum.py @@ -1,22 +1,19 @@ import logging -from datetime import datetime from dataclasses import dataclass -from typing import Optional, Literal, cast +from datetime import datetime +from typing import Literal, cast import aiohttp +from discord import Interaction +from discord.app_commands import Choice, command from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command -from discord.app_commands import Choice, choices from rocketwatch import RocketWatch -from utils.cfg import cfg from utils.embeds import Embed -from utils.visibility import is_hidden_weak -from utils.retry import retry_async +from utils.retry import retry +from utils.visibility import is_hidden -log = logging.getLogger("forum") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.forum") class Forum(commands.Cog): @@ -38,7 +35,7 @@ class Topic: @property def url(self) -> str: - return f"{Forum.DOMAIN}/t/{self.slug}" + return f"{Forum.DOMAIN}/t/{self.slug}/{self.id}" def __str__(self) -> str: return self.title @@ -47,7 +44,7 @@ def __str__(self) -> str: class User: id: int username: str - name: Optional[str] + name: str | None topic_count: int post_count: int likes_received: int @@ -69,20 +66,22 @@ def datetime_to_epoch(_dt: str) -> int: topics = [] for topic_dict in topic_list: - topics.append(Forum.Topic( - id=topic_dict["id"], - title=topic_dict["fancy_title"], - slug=topic_dict["slug"], - post_count=topic_dict["posts_count"], - created_at=datetime_to_epoch(topic_dict["created_at"]), - last_post_at=datetime_to_epoch(topic_dict["last_posted_at"]), - views=topic_dict["views"], - like_count=topic_dict["like_count"] - )) + topics.append( + Forum.Topic( + id=topic_dict["id"], + title=topic_dict["fancy_title"], + slug=topic_dict["slug"], + post_count=topic_dict["posts_count"], + created_at=datetime_to_epoch(topic_dict["created_at"]), + last_post_at=datetime_to_epoch(topic_dict["last_posted_at"]), + views=topic_dict["views"], + like_count=topic_dict["like_count"], + ) + ) return topics @staticmethod - @retry_async(tries=3, delay=1) + @retry(tries=3, delay=2, backoff=2) async def get_popular_topics(period: Period) -> list[Topic]: async with aiohttp.ClientSession() as session: response = await session.get(f"{Forum.DOMAIN}/top.json?period={period}") @@ -91,7 +90,7 @@ async def get_popular_topics(period: Period) -> list[Topic]: return Forum._parse_topics(data["topic_list"]["topics"]) @staticmethod - @retry_async(tries=3, delay=1) + @retry(tries=3, delay=2, backoff=2) async def get_recent_topics() -> list[Topic]: async with aiohttp.ClientSession() as session: response = await session.get(f"{Forum.DOMAIN}/latest.json") @@ -100,37 +99,42 @@ async def get_recent_topics() -> list[Topic]: return Forum._parse_topics(data["topic_list"]["topics"]) @staticmethod - @retry_async(tries=3, delay=1) + @retry(tries=3, delay=2, backoff=2) async def get_top_users(period: Period, order_by: UserMetric) -> list[User]: async with aiohttp.ClientSession() as session: - response = await session.get(f"{Forum.DOMAIN}/directory_items.json?period={period}&order={order_by}") + response = await session.get( + f"{Forum.DOMAIN}/directory_items.json?period={period}&order={order_by}" + ) data = await response.json() users = [] for user_dict in data["directory_items"]: - users.append(Forum.User( - id=user_dict["id"], - username=user_dict["user"]["username"], - name=user_dict["user"]["name"] if user_dict["user"]["name"] else None, - topic_count=user_dict["topic_count"], - post_count=user_dict["post_count"], - likes_received=user_dict["likes_received"] - )) + users.append( + Forum.User( + id=user_dict["id"], + username=user_dict["user"]["username"], + name=user_dict["user"]["name"] + if user_dict["user"]["name"] + else None, + topic_count=user_dict["topic_count"], + post_count=user_dict["post_count"], + likes_received=user_dict["likes_received"], + ) + ) return users - @hybrid_command() + @command() async def top_forum_posts( - self, - ctx: Context, - period: Period = "monthly" + self, interaction: Interaction, period: Period = "monthly" ) -> None: """Get the most popular topics from the forum""" - await ctx.defer(ephemeral=is_hidden_weak(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) if isinstance(period, Choice): - period: Forum.Period = cast(Forum.Period, period.value) + period = cast(Forum.Period, period.value) - embed = Embed(title=f"Top Forum Posts ({period})", description="") + embed = Embed(title=f"Top Forum Posts ({period})") + embed.description = "" if topics := await self.get_popular_topics(period): for i, topic in enumerate(topics[:10], start=1): @@ -142,22 +146,20 @@ async def top_forum_posts( else: embed.description = "No topics found." - await ctx.send(embed=embed) + await interaction.followup.send(embed=embed) - @hybrid_command() + @command() async def top_forum_users( self, - ctx: Context, + interaction: Interaction, period: Period = "monthly", - order_by: UserMetric = "likes_received" + order_by: UserMetric = "likes_received", ) -> None: """Get the most active forum users""" - await ctx.defer(ephemeral=is_hidden_weak(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) - embed = Embed( - title=f"Top Forum Users ({period})", - description="" - ) + embed = Embed(title=f"Top Forum Users ({period})") + embed.description = "" users = await self.get_top_users(period, order_by) if users: @@ -169,7 +171,7 @@ async def top_forum_users( else: embed.description = "No users found." - await ctx.send(embed=embed) + await interaction.followup.send(embed=embed) async def setup(bot): diff --git a/rocketwatch/plugins/governance/governance.py b/rocketwatch/plugins/governance/governance.py index 753ff351..8afad26b 100644 --- a/rocketwatch/plugins/governance/governance.py +++ b/rocketwatch/plugins/governance/governance.py @@ -1,48 +1,57 @@ import logging from datetime import datetime, timedelta -from discord.ext.commands import Context, hybrid_command +from discord import Interaction +from discord.app_commands import command from discord.utils import escape_markdown from eth_typing import HexStr from web3.constants import HASH_ZERO -from plugins.snapshot.snapshot import Snapshot from plugins.forum.forum import Forum from plugins.rpips.rpips import RPIPs - -from utils.status import StatusPlugin -from utils.cfg import cfg -from utils.dao import DAO, DefaultDAO, OracleDAO, SecurityCouncil, ProtocolDAO -from utils.embeds import Embed -from utils.visibility import is_hidden_weak +from plugins.snapshot.snapshot import Snapshot from utils.block_time import ts_to_block +from utils.config import cfg +from utils.dao import DAO, DefaultDAO, OracleDAO, ProtocolDAO, SecurityCouncil +from utils.embeds import Embed +from utils.status import StatusPlugin +from utils.visibility import is_hidden -log = logging.getLogger("governance") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.governance") class Governance(StatusPlugin): @staticmethod - def _get_active_pdao_proposals(dao: ProtocolDAO) -> list[ProtocolDAO.Proposal]: - proposals = dao.get_proposals_by_state() + async def _get_active_pdao_proposals( + dao: ProtocolDAO, + ) -> list[ProtocolDAO.Proposal]: + proposal_ids = await dao.get_proposal_ids_by_state() active_proposal_ids = [] - active_proposal_ids += proposals[dao.ProposalState.ActivePhase1] - active_proposal_ids += proposals[dao.ProposalState.ActivePhase2] - return [dao.fetch_proposal(proposal_id) for proposal_id in reversed(active_proposal_ids)] + active_proposal_ids += proposal_ids[dao.ProposalState.ActivePhase1] + active_proposal_ids += proposal_ids[dao.ProposalState.ActivePhase2] + return [ + await dao.fetch_proposal(proposal_id) + for proposal_id in reversed(active_proposal_ids) + ] @staticmethod - def _get_active_dao_proposals(dao: DefaultDAO) -> list[DefaultDAO.Proposal]: - proposals = dao.get_proposals_by_state() - active_proposal_ids = proposals[dao.ProposalState.Active] - return [dao.fetch_proposal(proposal_id) for proposal_id in reversed(active_proposal_ids)] + async def _get_active_dao_proposals(dao: DefaultDAO) -> list[DefaultDAO.Proposal]: + proposal_ids = await dao.get_proposal_ids_by_state() + active_proposal_ids = proposal_ids[dao.ProposalState.Active] + return [ + await dao.fetch_proposal(proposal_id) + for proposal_id in reversed(active_proposal_ids) + ] @staticmethod - def _get_tx_hash_for_proposal(dao: DAO, proposal: DAO.Proposal) -> HexStr: - from_block = ts_to_block(proposal.created) - 1 - to_block = ts_to_block(proposal.created) + 1 + async def _get_tx_hash_for_proposal(dao: DAO, proposal: DAO.Proposal) -> HexStr: + from_block = (await ts_to_block(proposal.created)) - 1 + to_block = (await ts_to_block(proposal.created)) + 1 log.info(f"Looking for proposal {proposal} in [{from_block},{to_block}]") - for receipt in dao.proposal_contract.events.ProposalAdded().get_logs(fromBlock=from_block, toBlock=to_block): + for receipt in dao.proposal_contract.events.ProposalAdded().get_logs( + from_block=from_block, to_block=to_block + ): log.info(f"Found receipt {receipt}") if receipt.args.proposalID == proposal.id: return receipt.transactionHash.hex() @@ -51,14 +60,19 @@ def _get_tx_hash_for_proposal(dao: DAO, proposal: DAO.Proposal) -> HexStr: async def _get_active_snapshot_proposals(self) -> list[Snapshot.Proposal]: try: - return Snapshot.fetch_proposals("active", reverse=True) + return await Snapshot.fetch_proposals("active", reverse=True) except Exception as e: await self.bot.report_error(e) return [] async def _get_draft_rpips(self) -> list[RPIPs.RPIP]: try: - return [rpip for rpip in RPIPs.get_all_rpips() if (rpip.status == "Draft")][::-1] + statuses = {"Draft", "Review"} + return [ + rpip + for rpip in await RPIPs.get_all_rpips() + if (rpip.status in statuses) + ][::-1] except Exception as e: await self.bot.report_error(e) return [] @@ -68,14 +82,19 @@ async def _get_latest_forum_topics(self, days: int) -> list[Forum.Topic]: topics = await Forum.get_recent_topics() now = datetime.now().timestamp() # only get topics from within a week - topics = [t for t in topics if (now - t.last_post_at) <= timedelta(days=days).total_seconds()] + topics = [ + t + for t in topics + if (now - t.last_post_at) <= timedelta(days=days).total_seconds() + ] return topics except Exception as e: await self.bot.report_error(e) return [] async def get_digest(self) -> Embed: - embed = Embed(title="Governance Digest", description="") + embed = Embed(title="Governance Digest") + embed.description = "" def sanitize(text: str, max_length: int = 50) -> str: text = text.strip() @@ -83,42 +102,42 @@ def sanitize(text: str, max_length: int = 50) -> str: text = text.replace("https://", "") text = escape_markdown(text) if len(text) > max_length: - text = text[:(max_length - 1)] + "…" + text = text[: (max_length - 1)] + "…" return text - def print_proposals(_dao: DAO, _proposals: list[DAO.Proposal]) -> str: + async def print_proposals(_dao: DAO, _proposals: list[DAO.Proposal]) -> str: text = "" for _i, _proposal in enumerate(_proposals, start=1): _title = sanitize(_proposal.message, 40) - _tx_hash = self._get_tx_hash_for_proposal(_dao, _proposal) - _url = f"{cfg['execution_layer.explorer']}/tx/{_tx_hash}" + _tx_hash = await self._get_tx_hash_for_proposal(_dao, _proposal) + _url = f"{cfg.execution_layer.explorer}/tx/{_tx_hash}" text += f" {_i}. [{_title}]({_url}) (#{_proposal.id})\n" return text - + # --------- SECURITY COUNCIL --------- # dao = SecurityCouncil() - if proposals := self._get_active_dao_proposals(dao): + if proposals := await self._get_active_dao_proposals(dao): embed.description += "### Security Council\n" - embed.description += "- **Active proposals**\n" - embed.description += print_proposals(dao, proposals) - + embed.description += "- **Active on-chain proposals**\n" + embed.description += await print_proposals(dao, proposals) + # --------- ORACLE DAO --------- # dao = OracleDAO() - if proposals := self._get_active_dao_proposals(dao): + if proposals := await self._get_active_dao_proposals(dao): embed.description += "### Oracle DAO\n" - embed.description += "- **Active proposals**\n" - embed.description += print_proposals(dao, proposals) + embed.description += "- **Active on-chain proposals**\n" + embed.description += await print_proposals(dao, proposals) # --------- PROTOCOL DAO --------- # section_content = "" dao = ProtocolDAO() - if proposals := self._get_active_pdao_proposals(dao): + if proposals := await self._get_active_pdao_proposals(dao): section_content += "- **Active on-chain proposals**\n" - section_content += print_proposals(dao, proposals) + section_content += await print_proposals(dao, proposals) if snapshot_proposals := await self._get_active_snapshot_proposals(): section_content += "- **Active Snapshot proposals**\n" @@ -127,10 +146,12 @@ def print_proposals(_dao: DAO, _proposals: list[DAO.Proposal]) -> str: section_content += f" {i}. [{title}]({proposal.url})\n" if draft_rpips := await self._get_draft_rpips(): - section_content += "- **RPIPs in draft status**\n" + section_content += "- **RPIPs in review or draft status**\n" for i, rpip in enumerate(draft_rpips, start=1): title = sanitize(rpip.title, 40) - section_content += f" {i}. [{title}]({rpip.url}) (RPIP-{rpip.number})\n" + section_content += ( + f" {i}. [{title}]({rpip.url}) (RPIP-{rpip.number})\n" + ) if section_content: embed.description += "### Protocol DAO\n" @@ -144,19 +165,19 @@ def print_proposals(_dao: DAO, _proposals: list[DAO.Proposal]) -> str: embed.description += f"- **Recently active topics ({num_days}d)**\n" for i, topic in enumerate(topics[:10], start=1): title = sanitize(topic.title, 40) - embed.description += f" {i}. [{title}]({topic.url}) [`{topic.post_count-1}\u202f💬`]\n" + embed.description += f" {i}. [{title}]({topic.url}) [`{topic.post_count - 1}\u202f💬`]\n" if not embed.description: embed.set_image(url="https://c.tenor.com/PVf-csSHmu8AAAAd/tenor.gif") return embed - @hybrid_command() - async def governance_digest(self, ctx: Context) -> None: + @command() + async def governance_digest(self, interaction: Interaction) -> None: """Get a summary of recent activity in protocol governance""" - await ctx.defer(ephemeral=is_hidden_weak(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) embed = await self.get_digest() - await ctx.send(embed=embed) + await interaction.followup.send(embed=embed) async def get_status(self) -> Embed: embed = await self.get_digest() diff --git a/rocketwatch/plugins/karma/karma.py b/rocketwatch/plugins/karma/karma.py deleted file mode 100644 index c94c48f1..00000000 --- a/rocketwatch/plugins/karma/karma.py +++ /dev/null @@ -1,166 +0,0 @@ -import logging - -from discord import app_commands, Interaction, User, AppCommandType -from discord.app_commands.checks import cooldown -from discord.ext.commands import Cog, GroupCog -from motor.motor_asyncio import AsyncIOMotorClient -from pymongo import IndexModel - -from rocketwatch import RocketWatch -from utils.cfg import cfg -from utils.embeds import Embed -from utils.visibility import is_hidden_weak - -log = logging.getLogger("karma") -log.setLevel(cfg["log_level"]) - - -class KarmaUtils(GroupCog, name="karma"): - def __init__(self, bot: RocketWatch): - self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).get_database("rocketwatch") - self.menus = [] - for c in [5,10]: - self.menus.append(app_commands.ContextMenu( - name=f"Give {c} Point{'s' if c != 1 else ''}", - callback=self.add_user_points, - type=AppCommandType.user, - guild_ids=[cfg["rocketpool.support.server_id"]], - extras={"amount": c} - )) - self.menus.append(app_commands.ContextMenu( - name=f"Remove {c} Point{'s' if c != 1 else ''}", - callback=self.remove_user_points, - type=AppCommandType.user, - guild_ids=[cfg["rocketpool.support.server_id"]], - extras={"amount": c} - )) - - for menu in self.menus: - self.bot.tree.add_command(menu) - - @Cog.listener() - async def on_ready(self): - # ensure user and issuer indexes exist - await self.db.karma.create_indexes([ - IndexModel("user"), - IndexModel("issuer") - ]) - - async def cog_unload(self) -> None: - for menu in self.menus: - self.bot.tree.remove_command(menu) - - @app_commands.guilds(cfg["rocketpool.support.server_id"]) - @cooldown(1, 10) - async def add_user_points(self, interaction: Interaction, user: User): - await interaction.response.defer(ephemeral=True) - # dissallow users from giving themselves points - if user.id == interaction.user.id: - await interaction.edit_original_response( - content="You can't give yourself points!", - ) - return - amount = interaction.command.extras["amount"] - await self.db.karma.update_one( - {"user": user.id, "issuer": interaction.user.id}, - {"$inc": {"points": amount}}, - upsert=True - ) - # create a self-deleting announcement message - await interaction.channel.send( - f"Gave {amount} `{interaction.user.global_name or interaction.user.name}`" - f" point{'s' if amount != 1 else ''} to `{user.global_name or user.name}`!", - delete_after=30 - ) - await interaction.delete_original_response() - - @app_commands.guilds(cfg["rocketpool.support.server_id"]) - @cooldown(1, 10) - async def remove_user_points(self, interaction: Interaction, user: User): - await interaction.response.defer(ephemeral=True) - # dissallow users from giving themselves points - if user.id == interaction.user.id: - await interaction.edit_original_response( - content="You can't remove points from yourself!", - ) - return - amount = interaction.command.extras["amount"] - await self.db.karma.update_one( - {"user": user.id, "issuer": interaction.user.id}, - {"$inc": {"points": -amount}}, - upsert=True - ) - # create a self-deleting announcement message - await interaction.channel.send( - f"Removed {amount} `{interaction.user.global_name or interaction.user.name}`" - f" point{'s' if amount != 1 else ''} from `{user.global_name or user.name}`!", - delete_after=30 - ) - await interaction.delete_original_response() - - @app_commands.command(name="top") - async def top(self, interaction: Interaction): - await interaction.response.defer(ephemeral=is_hidden_weak(interaction)) - # find the top karma users - top = await self.db.karma.aggregate([ - {"$group": {"_id": "$user", "points": {"$sum": "$points"}}}, - {"$sort": {"points": -1}}, - {"$limit": 10}, - {"$lookup": { - "from" : "karma", - "let" : {"user_id": "$_id"}, - "pipeline": [ - {"$match": {"$expr": {"$eq": ["$user", "$$user_id"]}}}, - {"$group": {"_id": "$issuer", "total": {"$sum": "$points"}}}, - {"$sort": {"total": -1}}, - {"$limit": 1} - ], - "as" : "top_issuer" - }}, - {"$project": {"_id": 1, "points": 1, "issuer": {"$arrayElemAt": ["$top_issuer._id", 0]}}} - ]).to_list(length=10) - e = Embed(title="Top 10 Karma Users") - des = "" - for i, u in enumerate(top): - # try to resolve users - user = self.bot.get_user(u["_id"]) - if not user: - user = await self.bot.fetch_user(u["_id"]) - issuer = self.bot.get_user(u["issuer"]) - if not issuer: - issuer = await self.bot.fetch_user(u["issuer"]) - des += f"`{f'#{str(i + 1)}':>3}` {user.mention} – `{u['points']}` points (most given by {issuer.mention})\n" - - e.description = des - await interaction.edit_original_response(embed=e) - - # user lookup command, defaults to caller. top 10 points split by issuer - @app_commands.command(name="user") - async def user(self, interaction: Interaction, user: User = None): - await interaction.response.defer(ephemeral=is_hidden_weak(interaction) or not user or user.id == interaction.user.id) - if not user: - user = interaction.user - # find the top karma users - top = await self.db.karma.find({"user": user.id}).sort("points", -1).to_list(length=10) - if not top: - await interaction.edit_original_response(content=f"`{user.global_name or user.name}` has no points!") - return - # fetch total score for user - total = await self.db.karma.aggregate([ - {"$match": {"user": user.id}}, - {"$group": {"_id": "$user", "points": {"$sum": "$points"}}} - ]).to_list(length=1) - e = Embed(title=f"Points held by {user.global_name or user.name}") - des = "" - if total: - des += f"**Total points: `{total[0]['points']}`**\n" - for u in top: - issuer = self.bot.get_user(u["issuer"]) or await self.bot.fetch_user(u["issuer"]) - des += f"– `{u['points']}` points received from {issuer.mention}\n" - e.description = des - await interaction.edit_original_response(embed=e) - - -async def setup(self): - await self.add_cog(KarmaUtils(self)) diff --git a/rocketwatch/plugins/lottery/lottery.py b/rocketwatch/plugins/lottery/lottery.py index c204dce4..73b5d30b 100644 --- a/rocketwatch/plugins/lottery/lottery.py +++ b/rocketwatch/plugins/lottery/lottery.py @@ -1,159 +1,118 @@ import logging +from typing import TypedDict +from discord import Interaction +from discord.app_commands import command from discord.ext import commands -from discord.ext.commands import hybrid_command, Context -from motor.motor_asyncio import AsyncIOMotorClient -from pymongo import InsertOne from rocketwatch import RocketWatch -from utils.cfg import cfg -from utils.embeds import Embed -from utils.embeds import el_explorer_url +from utils.embeds import Embed, el_explorer_url from utils.shared_w3 import bacon -from utils.solidity import BEACON_START_DATE, BEACON_EPOCH_LENGTH -from utils.time_debug import timerun_async +from utils.solidity import BEACON_EPOCH_LENGTH, BEACON_START_DATE from utils.visibility import is_hidden -log = logging.getLogger("lottery") -log.setLevel(cfg["log_level"]) - - -class LotteryBase: - def __init__(self): - # connect to local mongodb - self.client = AsyncIOMotorClient(cfg["mongodb.uri"]) - self.db = self.client.get_database("rocketwatch") - self.did_check = False - - async def _check_indexes(self): - if self.did_check: - return - log.debug("Checking indexes") - for period in ["latest", "next"]: - col = self.db[f"sync_committee_{period}"] - await col.create_index("validator", unique=True) - await col.create_index("index", unique=True) - self.did_check = True - log.debug("Indexes checked") - - @timerun_async - async def load_sync_committee(self, period): - assert period in ["latest", "next"] - await self._check_indexes() - h = bacon.get_block("head") - sync_period = int(h['data']['message']['slot']) // 32 // 256 - if period == "next": - sync_period += 1 - res = bacon._make_get_request(f"/eth/v1/beacon/states/head/sync_committees?epoch={sync_period * 256}") - data = res["data"] - self.db.sync_committee_stats.replace_one({"period": period}, - {"period" : period, - "start_epoch": sync_period * 256, - "end_epoch" : (sync_period + 1) * 256, - "sync_period": sync_period * 256, - }, upsert=True) - validators = data["validators"] - col = self.db[f"sync_committee_{period}"] - # get unique validators from collection - validators_in_db = await col.distinct("validator") - if set(validators) == set(validators_in_db): - return - payload = [ - InsertOne({"index": i, "validator": int(validator)}) - for i, validator in enumerate(validators) - ] - async with await self.client.start_session() as s: - async with s.start_transaction(): - await col.delete_many({}) - await col.bulk_write(payload) - - async def get_validators_for_sync_committee_period(self, period): - data = await self.db[f"sync_committee_{period}"].aggregate([ - { - '$lookup': { - 'from' : 'minipools', - 'localField' : 'validator', - 'foreignField': 'validator', - 'as' : 'entry' - } - }, { - '$match': { - 'entry': { - '$ne': [] - } - } - }, { - '$replaceRoot': { - 'newRoot': { - '$first': '$entry' - } - } - }, { - '$project': { - '_id' : 0, - 'validator' : 1, - 'pubkey' : 1, - 'node_operator': 1 - } - }, { - '$match': { - 'node_operator': { - '$ne': None - } - } - }]).to_list(length=None) - - return data - - async def generate_sync_committee_description(self, period): - await self.load_sync_committee(period) - validators = await self.get_validators_for_sync_committee_period(period) - # get stats about the current period - stats = await self.db.sync_committee_stats.find_one({"period": period}) - perc = len(validators) / 512 - description = f"_Rocket Pool Participation:_ {len(validators)}/512 ({perc:.2%})\n" - start_timestamp = BEACON_START_DATE + (stats['start_epoch'] * BEACON_EPOCH_LENGTH) - description += f"_Start:_ Epoch {stats['start_epoch']} ()\n" - end_timestamp = BEACON_START_DATE + (stats['end_epoch'] * BEACON_EPOCH_LENGTH) - description += f"_End:_ Epoch {stats['end_epoch']} ()\n" - # validators (called minipools here) - # sort validators - validators.sort(key=lambda x: x['validator']) - description += f"_Minipools:_ `{', '.join(str(v['validator']) for v in validators)}`\n" - # node operators - # gather count per - node_operators = {} - for v in validators: - if v['node_operator'] not in node_operators: - node_operators[v['node_operator']] = 0 - node_operators[v['node_operator']] += 1 - # sort by count - node_operators = sorted(node_operators.items(), key=lambda x: x[1], reverse=True) - description += "_Node Operators:_ " - description += ", ".join([f"{count}x {el_explorer_url(node_operator)}" for node_operator, count in - node_operators]) - return description +log = logging.getLogger("rocketwatch.lottery") + +class ValidatorEntry(TypedDict): + validator: int + pubkey: str + node_operator: str -lottery = LotteryBase() + +class SyncCommittee(TypedDict): + start_epoch: int + end_epoch: int + validators: list[ValidatorEntry] class Lottery(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - @hybrid_command() - async def lottery(self, ctx: Context): + COMMITTEE_SIZE = 512 + + async def get_sync_committee_data(self, period: int) -> SyncCommittee: + data = (await bacon.get_sync_committee(period * 256))["data"] + validators = [int(v) for v in data["validators"]] + projection = {"_id": 0, "validator_index": 1, "pubkey": 1, "node_operator": 1} + query = {"validator_index": {"$in": validators}} + minipool_results = await self.bot.db.minipools.find(query, projection).to_list() + megapool_results = await self.bot.db.megapool_validators.find( + query, projection + ).to_list() + return { + "start_epoch": period * 256, + "end_epoch": (period + 1) * 256, + "validators": [ + { + "validator": result["validator_index"], + "pubkey": result["pubkey"], + "node_operator": result["node_operator"], + } + for result in (minipool_results + megapool_results) + if result.get("node_operator") is not None + ], + } + + async def generate_sync_committee_description(self, period: int) -> str: + data = await self.get_sync_committee_data(period) + validators = data["validators"] + perc = len(validators) / Lottery.COMMITTEE_SIZE + description = f"**Rocket Pool Participation**: {len(validators)}/{Lottery.COMMITTEE_SIZE} ({perc:.2%})\n" + start_timestamp = BEACON_START_DATE + ( + data["start_epoch"] * BEACON_EPOCH_LENGTH + ) + description += f"**Start**: Epoch {data['start_epoch']} ()\n" + end_timestamp = BEACON_START_DATE + (data["end_epoch"] * BEACON_EPOCH_LENGTH) + description += f"**End**: Epoch {data['end_epoch']} ()\n" + validators.sort(key=lambda x: x["validator"]) + description += ( + f"**Validators**: `{', '.join(str(v['validator']) for v in validators)}`\n" + ) + node_operator_counts: dict[str, int] = {} + for v in validators: + if v["node_operator"] not in node_operator_counts: + node_operator_counts[v["node_operator"]] = 0 + node_operator_counts[v["node_operator"]] += 1 + sorted_operators = sorted( + node_operator_counts.items(), key=lambda x: x[1], reverse=True + ) + description += "**Node Operators**: " + description += ", ".join( + [ + f"{count}x {await el_explorer_url(node_operator)}" + for node_operator, count in sorted_operators + ] + ) + return description + + @command() + async def lottery(self, interaction: Interaction): """ Get the status of the current and next sync committee. """ - await ctx.defer(ephemeral=is_hidden(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) + + header = await bacon.get_block("head") + current_period = int(header["data"]["message"]["slot"]) // 32 // 256 + embeds = [ - Embed(title="Current sync committee:", description=await lottery.generate_sync_committee_description("latest")), - Embed(title="Next sync committee:", description=await lottery.generate_sync_committee_description("next")) + Embed( + title="Current Sync Committee", + description=await self.generate_sync_committee_description( + current_period + ), + ), + Embed( + title="Next Sync Committee", + description=await self.generate_sync_committee_description( + current_period + 1 + ), + ), ] - await ctx.send(embeds=embeds) + await interaction.followup.send(embeds=embeds) -async def setup(bot): +async def setup(bot: RocketWatch) -> None: await bot.add_cog(Lottery(bot)) diff --git a/rocketwatch/plugins/metrics/metrics.py b/rocketwatch/plugins/metrics/metrics.py index 8d2fb97c..2f125da9 100644 --- a/rocketwatch/plugins/metrics/metrics.py +++ b/rocketwatch/plugins/metrics/metrics.py @@ -1,227 +1,204 @@ import logging -import math -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from io import BytesIO -from motor.motor_asyncio import AsyncIOMotorClient from bson import SON -from cachetools import TTLCache -from discord import File +from discord import File, Interaction +from discord.app_commands import command from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command from matplotlib import pyplot as plt from rocketwatch import RocketWatch -from utils.cfg import cfg from utils.embeds import Embed from utils.visibility import is_hidden -log = logging.getLogger("metrics") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.metrics") class Metrics(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.notice_ttl_cache = TTLCache(math.inf, ttl=60 * 15) - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch - self.collection = self.db.command_metrics + self.collection = self.bot.db.command_metrics - @hybrid_command() - async def metrics(self, ctx: Context): + @command() + async def metrics(self, interaction: Interaction): """ Show various metrics about the bot. """ - await ctx.defer(ephemeral=is_hidden(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) try: e = Embed(title="Metrics from the last 7 days") desc = "```\n" # last 7 days - start = datetime.utcnow() - timedelta(days=7) + start = datetime.now(UTC) - timedelta(days=7) # get the total number of processed events from the event_queue in the last 7 days - total_events_processed = await self.db.event_queue.count_documents({'time_seen': {'$gte': start}}) + total_events_processed = await self.bot.db.event_queue.count_documents( + {"time_seen": {"$gte": start}} + ) desc += f"Total Events Processed:\n\t{total_events_processed}\n\n" # get the total number of handled commands in the last 7 days - total_commands_handled = await self.collection.count_documents({'timestamp': {'$gte': start}}) + total_commands_handled = await self.collection.count_documents( + {"timestamp": {"$gte": start}} + ) desc += f"Total Commands Handled:\n\t{total_commands_handled}\n\n" # get the average command response time in the last 7 days - avg_response_time = await self.collection.aggregate([ - {'$match': {'timestamp': {'$gte': start}}}, - {'$group': {'_id': None, 'avg': {'$avg': '$took'}}} - ]).to_list(length=1) - if avg_response_time[0]['avg'] is not None: + avg_response_time = await ( + await self.collection.aggregate( + [ + {"$match": {"timestamp": {"$gte": start}}}, + {"$group": {"_id": None, "avg": {"$avg": "$took"}}}, + ] + ) + ).to_list(length=1) + if avg_response_time[0]["avg"] is not None: desc += f"Average Command Response Time:\n\t{avg_response_time[0]['avg']:.03} seconds\n\n" # get completed rate in the last 7 days - completed_rate = await self.collection.aggregate([ - {'$match': {'timestamp': {'$gte': start}, 'status': 'completed'}}, - {'$group': {'_id': None, 'count': {'$sum': 1}}} - ]).to_list(length=1) + completed_rate = await ( + await self.collection.aggregate( + [ + { + "$match": { + "timestamp": {"$gte": start}, + "status": "completed", + } + }, + {"$group": {"_id": None, "count": {"$sum": 1}}}, + ] + ) + ).to_list(length=1) if completed_rate: - percent = completed_rate[0]['count'] / (total_commands_handled - 1) + percent = completed_rate[0]["count"] / (total_commands_handled - 1) desc += f"Command Success Rate:\n\t{percent:.03%}\n\n" # get the 5 most used commands of the last 7 days - most_used_commands = await self.collection.aggregate([ - {'$match': {'timestamp': {'$gte': start}}}, - {'$group': {'_id': '$command', 'count': {'$sum': 1}}}, - {'$sort': {'count': -1}} - ]).to_list(length=5) - desc += "Top 5 Commands based on usage:\n" + most_used_commands = await ( + await self.collection.aggregate( + [ + {"$match": {"timestamp": {"$gte": start}}}, + {"$group": {"_id": "$command", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ] + ) + ).to_list(length=5) + desc += "Command Usage:\n" for command in most_used_commands: desc += f" - {command['_id']}: {command['count']}\n" + top_users = await ( + await self.collection.aggregate( + [ + {"$match": {"timestamp": {"$gte": start}}}, + {"$group": {"_id": "$user", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ] + ) + ).to_list(length=5) + desc += "\nCommand Count By User:\n" + for user in top_users: + desc += f" - {user['_id']['name']}: {user['count']}\n" + # get the top 5 channels of the last 7 days - top_channels = await self.collection.aggregate([ - {'$match': {'timestamp': {'$gte': start}}}, - {'$group': {'_id': '$channel', 'count': {'$sum': 1}}}, - {'$sort': {'count': -1}} - ]).to_list(length=5) - desc += "\nTop 5 Channels based on commands handled:\n" + top_channels = await ( + await self.collection.aggregate( + [ + {"$match": {"timestamp": {"$gte": start}}}, + {"$group": {"_id": "$channel", "count": {"$sum": 1}}}, + {"$sort": {"count": -1}}, + ] + ) + ).to_list(length=5) + desc += "\nCommand Count By Channel:\n" for channel in top_channels: desc += f" - {channel['_id']['name']}: {channel['count']}\n" e.description = desc + "```" - await ctx.send(embed=e) + await interaction.followup.send(embed=e) except Exception as e: log.error(f"Failed to get command metrics: {e}") await self.bot.report_error(e) - @hybrid_command() - async def metrics_chart(self, ctx): - await ctx.defer(ephemeral=is_hidden(ctx)) + @command() + async def metrics_chart(self, interaction: Interaction): + await interaction.response.defer(ephemeral=is_hidden(interaction)) # generate mathplotlib chart that shows monthly command usage and monthly event emission, in separate subplots - command_usage = await self.collection.aggregate([ - { - '$group': { - '_id' : { - 'year' : {'$year': '$timestamp'}, - 'month': {'$month': '$timestamp'} + command_usage = await ( + await self.collection.aggregate( + [ + { + "$group": { + "_id": { + "year": {"$year": "$timestamp"}, + "month": {"$month": "$timestamp"}, + }, + "total": {"$sum": 1}, + } }, - 'total': {'$sum': 1} - } - }, - { - '$sort': SON([('_id.year', 1), ('_id.month', 1)]) - } - ]).to_list(None) - event_emission = await self.db.event_queue.aggregate([ - { - '$group': { - '_id' : { - 'year' : {'$year': '$time_seen'}, - 'month': {'$month': '$time_seen'} + {"$sort": SON([("_id.year", 1), ("_id.month", 1)])}, + ] + ) + ).to_list(None) + event_emission = await ( + await self.bot.db.event_queue.aggregate( + [ + { + "$group": { + "_id": { + "year": {"$year": "$time_seen"}, + "month": {"$month": "$time_seen"}, + }, + "total": {"$sum": 1}, + } }, - 'total': {'$sum': 1} - } - }, - { - '$sort': SON([('_id.year', 1), ('_id.month', 1)]) - } - ]).to_list(None) + {"$sort": SON([("_id.year", 1), ("_id.month", 1)])}, + ] + ) + ).to_list(None) # create a new figure fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 10)) # plot the command usage as bars - ax1.bar([f"{x['_id']['year']}-{x['_id']['month']:0>2}" for x in command_usage], [x['total'] for x in command_usage]) + ax1.bar( + [f"{x['_id']['year']}-{x['_id']['month']:0>2}" for x in command_usage], + [x["total"] for x in command_usage], + ) ax1.set_title("Command Usage") - ax1.set_xticklabels([f"{x['_id']['year']}-{x['_id']['month']:0>2}" for x in command_usage], rotation=45) + ax1.set_xticklabels( + [f"{x['_id']['year']}-{x['_id']['month']:0>2}" for x in command_usage], + rotation=45, + ) # plot the event usage - ax2.bar([f"{x['_id']['year']}-{x['_id']['month']:0>2}" for x in event_emission], [x['total'] for x in event_emission]) + ax2.bar( + [f"{x['_id']['year']}-{x['_id']['month']:0>2}" for x in event_emission], + [x["total"] for x in event_emission], + ) ax2.set_title("Event Emission") - ax2.set_xticklabels([f"{x['_id']['year']}-{x['_id']['month']:0>2}" for x in event_emission], rotation=45) + ax2.set_xticklabels( + [f"{x['_id']['year']}-{x['_id']['month']:0>2}" for x in event_emission], + rotation=45, + ) # use minimal whitespace - plt.tight_layout() + fig.tight_layout() # store the graph in an file object file = BytesIO() - plt.savefig(file, format='png') + fig.savefig(file, format="png") file.seek(0) # clear plot from memory - plt.clf() - plt.close() + plt.close(fig) e = Embed(title="Command Usage and Event ") e.set_image(url="attachment://metrics.png") - await ctx.send(embed=e, file=File(file, filename="metrics.png")) - - @commands.Cog.listener() - async def on_command(self, ctx): - log.info(f"/{ctx.command.name} triggered by {ctx.author} in #{ctx.channel.name} ({ctx.guild})") - try: - await self.collection.insert_one({ - '_id' : ctx.interaction.id, - 'command' : ctx.command.name, - 'options' : ctx.interaction.data.get("options", []), - 'user' : { - 'id' : ctx.author.id, - 'name': ctx.author.name, - }, - 'guild' : { - 'id' : ctx.guild.id, - 'name': ctx.guild.name, - }, - 'channel' : { - 'id' : ctx.channel.id, - 'name': ctx.channel.name, - }, - "timestamp": datetime.utcnow(), - 'status' : 'pending' - }) - except Exception as e: - log.error(f"Failed to insert command into database: {e}") - await self.bot.report_error(e) - - @commands.Cog.listener() - async def on_command_completion(self, ctx): - log.info( - f"/{ctx.command.name} called by {ctx.author} in #{ctx.channel.name} ({ctx.guild}) completed successfully") - if not is_hidden(ctx) and ctx.author not in self.notice_ttl_cache: - self.notice_ttl_cache[ctx.author] = True - e = Embed() - e.title = 'Did you know?' - e.description = "Calling this command (or any!) in other channels will make them only appear for you! " \ - "Give it a try next time!" - await ctx.reply(embed=e, ephemeral=True) - - try: - # get the timestamp of when the command was called from the db - data = await self.collection.find_one({'_id': ctx.interaction.id}) - await self.collection.update_one({'_id': ctx.interaction.id}, - { - '$set': { - 'status': 'completed', - 'took' : (datetime.utcnow() - data['timestamp']).total_seconds() - } - }) - except Exception as e: - log.error(f"Failed to update command status to completed: {e}") - await self.bot.report_error(e) - - @commands.Cog.listener() - async def on_command_error(self, ctx: Context, exception: Exception): - try: - # get the timestamp of when the command was called from the db - data = await self.collection.find_one({'_id': ctx.interaction.id}) - await self.collection.update_one( - {'_id': ctx.interaction.id}, - {'$set': { - 'status': 'error', - 'took': (datetime.now() - data['timestamp']).total_seconds(), - 'error': str(exception) - }} - ) - except Exception as e: - log.exception("Failed to update command status to error") - await self.bot.report_error(e) + await interaction.followup.send( + embed=e, file=File(file, filename="metrics.png") + ) async def setup(bot): diff --git a/rocketwatch/plugins/milestones/milestones.json b/rocketwatch/plugins/milestones/milestones.json deleted file mode 100644 index 91a648d7..00000000 --- a/rocketwatch/plugins/milestones/milestones.json +++ /dev/null @@ -1,60 +0,0 @@ -[ - { - "id": "milestone_rpl_stake", - "function": "call", - "args": [ - "rocketNodeStaking.getTotalRPLStake" - ], - "formatter": "to_float", - "min": 10000, - "step_size": 100000 - }, - { - "id": "milestone_max_deposit_size", - "function": "call", - "args": [ - "rocketDepositPool.getMaximumDepositAmount" - ], - "formatter": "to_float", - "min": 60000, - "step_size": 1000 - }, - { - "id": "milestone_reth_supply", - "function": "call", - "args": [ - "rocketTokenRETH.totalSupply" - ], - "formatter": "to_float", - "min": 1000, - "step_size": 5000 - }, - { - "id": "milestone_staking_minipools", - "function": "call", - "args": [ - "rocketMinipoolManager.getMinipoolCount" - ], - "formatter": "", - "min": 15, - "step_size": 250 - }, - { - "id": "milestone_rpl_swapped", - "function": "get_percentage_rpl_swapped", - "args": [], - "formatter": "", - "min": 1, - "step_size": 5 - }, - { - "id": "milestone_registered_nodes", - "function": "call", - "args": [ - "rocketNodeManager.getNodeCount" - ], - "formatter": "", - "min": 50, - "step_size": 100 - } -] diff --git a/rocketwatch/plugins/milestones/milestones.py b/rocketwatch/plugins/milestones/milestones.py index 5d39ae36..c05f644b 100644 --- a/rocketwatch/plugins/milestones/milestones.py +++ b/rocketwatch/plugins/milestones/milestones.py @@ -1,53 +1,88 @@ -import json import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass -import pymongo -from web3.datastructures import MutableAttributeDict as aDict +from web3.datastructures import MutableAttributeDict from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg from utils.embeds import assemble +from utils.event import Event, EventPlugin from utils.rocketpool import rp -from utils.event import EventPlugin, Event -log = logging.getLogger("milestones") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.milestones") + + +@dataclass(frozen=True, slots=True) +class Milestone: + id: str + min: int + step_size: int + call: Callable[[], Awaitable[float | int]] + + +def contract_call( + path: str, formatter: Callable[[int], float] | None = None +) -> Callable[[], Awaitable[float | int]]: + async def call(): + value = await rp.call(path) + return formatter(value) if formatter else value + + return call + + +async def _get_percentage_rpl_swapped() -> float: + value = solidity.to_float(await rp.call("rocketTokenRPL.totalSwappedRPL")) + return round((value / 18_000_000) * 100, 2) + + +MILESTONES: list[Milestone] = [ + Milestone( + id="milestone_rpl_stake", + min=10_000, + step_size=100_000, + call=contract_call("rocketNodeStaking.getTotalStakedRPL", solidity.to_float), + ), + Milestone( + id="milestone_reth_supply", + min=1_000, + step_size=5_000, + call=contract_call("rocketTokenRETH.totalSupply", solidity.to_float), + ), + Milestone( + id="milestone_rpl_swapped", + min=90, + step_size=1, + call=_get_percentage_rpl_swapped, + ), + Milestone( + id="milestone_registered_nodes", + min=50, + step_size=100, + call=contract_call("rocketNodeManager.getNodeCount"), + ), + Milestone( + id="milestone_rocksolid_tvl", + min=0, + step_size=5000, + call=contract_call("RockSolidVault.totalAssets", solidity.to_float), + ), +] class Milestones(EventPlugin): def __init__(self, bot: RocketWatch): super().__init__(bot) - self.db = pymongo.MongoClient(cfg["mongodb.uri"]).rocketwatch - self.collection = self.db.milestones - self.state = "OK" - - with open("./plugins/milestones/milestones.json") as f: - self.milestones = json.load(f) - - def _get_new_events(self) -> list[Event]: - if self.state == "RUNNING": - log.error("Milestones plugin was interrupted while running. Re-initializing...") - self.__init__(self.bot) - - self.state = "RUNNING" - result = self.check_for_new_events() - self.state = "OK" - return result - - # noinspection PyTypeChecker - def check_for_new_events(self): - log.info("Checking Milestones") - payload = [] + self.collection = self.bot.db.milestones - for milestone in self.milestones: - milestone = aDict(milestone) + async def _get_new_events(self) -> list[Event]: + log.info("Checking milestones") + payload = [] - state = self.collection.find_one({"_id": milestone["id"]}) + for milestone in MILESTONES: + state = await self.collection.find_one({"_id": milestone.id}) - value = getattr(rp, milestone.function)(*milestone.args) - if milestone.formatter: - value = getattr(solidity, milestone.formatter)(value) + value = await milestone.call() log.debug(f"{milestone.id}:{value}") if value < milestone.min: continue @@ -59,26 +94,36 @@ def check_for_new_events(self): previous_milestone = state["current_goal"] else: log.debug( - f"First time we have processed Milestones for milestone {milestone.id}. Adding it to the Database.") - self.collection.insert_one({"_id": milestone["id"], "current_goal": latest_goal}) + f"First time we have processed Milestones for milestone {milestone.id}. Adding it to the Database." + ) + await self.collection.insert_one( + {"_id": milestone.id, "current_goal": latest_goal} + ) previous_milestone = milestone.min if previous_milestone < latest_goal: - log.info(f"Goal for milestone {milestone.id} has increased. Triggering Milestone!") - embed = assemble(aDict({ - "event_name" : milestone.id, - "result_value": value - })) - payload.append(Event( - embed=embed, - topic="milestones", - block_number=self._pending_block, - event_name=milestone.id, - unique_id=f"{milestone.id}:{latest_goal}", - )) + log.info( + f"Goal for milestone {milestone.id} has increased. Triggering Milestone!" + ) + embed = await assemble( + MutableAttributeDict( + {"event_name": milestone.id, "result_value": value} + ) + ) + payload.append( + Event( + embed=embed, + topic="milestones", + block_number=self._pending_block, + event_name=milestone.id, + unique_id=f"{milestone.id}:{latest_goal}", + ) + ) # update the current goal in collection - self.collection.update_one({"_id": milestone["id"]}, {"$set": {"current_goal": latest_goal}}) + await self.collection.update_one( + {"_id": milestone.id}, {"$set": {"current_goal": latest_goal}} + ) - log.debug("Finished Checking Milestones") + log.debug("Finished checking milestones") return payload diff --git a/rocketwatch/plugins/minipool_distribution/minipool_distribution.py b/rocketwatch/plugins/minipool_distribution/minipool_distribution.py index ce9b7cfd..d0fd4df5 100644 --- a/rocketwatch/plugins/minipool_distribution/minipool_distribution.py +++ b/rocketwatch/plugins/minipool_distribution/minipool_distribution.py @@ -1,89 +1,74 @@ import logging import re from io import BytesIO +from typing import Any -import inflect import matplotlib.pyplot as plt import numpy as np -import pymongo -from discord import File -from discord.app_commands import describe +from discord import File, Interaction +from discord.app_commands import command, describe from discord.ext import commands -from discord.ext.commands import Context, hybrid_command from rocketwatch import RocketWatch -from utils.cfg import cfg from utils.embeds import Embed from utils.visibility import is_hidden -log = logging.getLogger("minipool_distribution") -log.setLevel(cfg["log_level"]) -p = inflect.engine() +log = logging.getLogger("rocketwatch.minipool_distribution") def get_percentiles(percentiles, counts): for p in percentiles: - yield p, np.percentile(counts, p, interpolation='nearest') + yield p, np.percentile(counts, p, method="nearest") -async def minipool_distribution_raw(ctx: Context, distribution): +async def minipool_distribution_raw(interaction: Interaction, distribution): e = Embed() e.title = "Minipool Distribution" description = "```\n" for minipools, nodes in distribution: - description += f"{p.no('minipool', minipools):>14}: " \ - f"{nodes:>4} {p.plural('node', nodes)}\n" + minipool_str = f"{minipools} {'minipool' if minipools == 1 else 'minipools'}" + description += ( + f"{minipool_str:>14}: {nodes:>4} {'node' if nodes == 1 else 'nodes'}\n" + ) description += "```" e.description = description - await ctx.send(embed=e) + await interaction.followup.send(embed=e) class MinipoolDistribution(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.db = pymongo.MongoClient(cfg["mongodb.uri"]).rocketwatch - def get_minipool_counts_per_node(self): + async def get_minipool_counts_per_node(self): # get an array for minipool counts per node from db using aggregation # example: [0,0,1,2,3,3,3] # 2 nodes have 0 minipools # 1 node has 1 minipool # 1 node has 2 minipools # 3 nodes have 3 minipools - pipeline = [ + pipeline: list[dict[str, Any]] = [ { - '$match': { - 'beacon.status': { - '$not': re.compile(r"(?:withdraw|exit|init)") - }, - 'status': 'staking' + "$match": { + "beacon.status": {"$not": re.compile(r"(?:withdraw|exit|init)")}, + "status": "staking", } - }, { - '$group': { - '_id': '$node_operator', - 'count': { - '$sum': 1 - } - } - }, { - '$sort': { - 'count': 1 - } - } + }, + {"$group": {"_id": "$node_operator", "count": {"$sum": 1}}}, + {"$sort": {"count": 1}}, + ] + return [ + x["count"] async for x in await self.bot.db.minipools.aggregate(pipeline) ] - return [x["count"] for x in self.db.minipools_new.aggregate(pipeline)] - @hybrid_command() + @command() @describe(raw="Show the raw Distribution Data") - async def minipool_distribution(self, - ctx: Context, - raw: bool = False): + async def minipool_distribution(self, interaction: Interaction, raw: bool = False): """Show the distribution of minipools per node.""" - await ctx.defer(ephemeral=is_hidden(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) e = Embed() # Get the minipool distribution - counts = self.get_minipool_counts_per_node() + counts = await self.get_minipool_counts_per_node() # Converts the array of counts, eg [ 0, 0, 0, 1, 1, 2 ], to a list of tuples # where the first item is the number of minipools and the second item is the # number of nodes, eg [ (0, 3), (1, 2), (2, 1) ] @@ -92,26 +77,25 @@ async def minipool_distribution(self, # If the raw data were requested, print them and exit early if raw: - await minipool_distribution_raw(ctx, distribution[::-1]) + await minipool_distribution_raw(interaction, distribution[::-1]) return img = BytesIO() fig, ax = plt.subplots(1, 1) # First chart is sorted bars showing total minipools provided by nodes with x minipools per node - bars = {x: x * y for x, y in distribution} # Remove the 0,0 value, since it doesn't provide any insight - x_keys = [str(x) for x in bars] - rects = ax.bar(x_keys, bars.values(), color=str(e.color)) + x_keys = [str(x) for x, _ in distribution] + rects = ax.bar(x_keys, [x * y for x, y in distribution], color=str(e.color)) ax.bar_label(rects, rotation=90, padding=3, fontsize=7) ax.set_ylabel("Total Minipools") # tilt the x axis labels - ax.tick_params(axis='x', labelrotation=90, labelsize=7) + ax.tick_params(axis="x", labelrotation=90, labelsize=7) # Add a 5% buffer to the ylim to help fit all the bar labels ax.set_ylim(top=(ax.get_ylim()[1] * 1.1)) fig.tight_layout() - fig.savefig(img, format='png') + fig.savefig(img, format="png") img.seek(0) fig.clear() @@ -120,25 +104,28 @@ async def minipool_distribution(self, e.title = "Minipool Distribution" e.set_image(url="attachment://graph.png") f = File(img, filename="graph.png") - percentile_strings = [f"{x[0]}th percentile: {p.no('minipool', int(x[1]))} per node" for x in - get_percentiles([50, 75, 90, 99], counts) if x[1]] + percentile_strings = [ + f"{x[0]}th percentile: {x[1]} minipools per node" + for x in get_percentiles([50, 75, 90, 99], counts) + if x[1] + ] percentile_strings.append(f"Max: {distribution[-1][0]} minipools per node") - percentile_strings.append(f"Total: {p.no('minipool', sum(counts))}") + percentile_strings.append(f"Total: {sum(counts)} minipools") e.set_footer(text="\n".join(percentile_strings)) - await ctx.send(embed=e, files=[f]) + await interaction.followup.send(embed=e, files=[f]) img.close() - @hybrid_command() + @command() @describe(raw="Show the raw distribution data") - async def node_gini(self, ctx: Context, raw: bool = False): + async def node_gini(self, interaction: Interaction, raw: bool = False): """ Show the cumulative validator share of the largest nodes. """ - await ctx.defer(ephemeral=is_hidden(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) e = Embed() e.title = "Validator Share of Largest Nodes" - minipool_counts = np.array(self.get_minipool_counts_per_node()) + minipool_counts = np.array(await self.get_minipool_counts_per_node()) # sort descending minipool_counts[::-1].sort() @@ -149,14 +136,17 @@ async def node_gini(self, ctx: Context, raw: bool = False): # calculate gini coefficient from sorted list counts_nz = minipool_counts[minipool_counts != 0] n_nz = counts_nz.size - gini = -(((2 * np.arange(1, n_nz + 1) - n_nz - 1) * counts_nz).sum() / (n_nz * counts_nz.sum())) + gini = -( + ((2 * np.arange(1, n_nz + 1) - n_nz - 1) * counts_nz).sum() + / (n_nz * counts_nz.sum()) + ) e.set_footer(text=f"Gini coefficient: {gini:.4f}") if raw: description = "" # count number of nodes in 5% intervals + significant thresholds - ticks = list(np.arange(0.05, 1, 0.05)) + [1 / 3, 2 / 3, 1.0] + ticks = [*list(np.arange(0.05, 1, 0.05)), 1 / 3, 2 / 3, 1.0] for threshold in sorted(ticks): index = y.searchsorted(threshold) num_nodes = x[index] @@ -165,7 +155,7 @@ async def node_gini(self, ctx: Context, raw: bool = False): description += f"\nTotal: {x[-1]} nodes" e.description = description - await ctx.send(embed=e) + await interaction.followup.send(embed=e) return fig, ax = plt.subplots(1, 1) @@ -184,7 +174,7 @@ def draw_threshold(threshold: float, color: str) -> None: x_pos = x[index] percentage = round(100 * threshold) x_ticks.append(x_pos) - ax.axvline(x=x_pos, linestyle='--', c=color, label=f'{percentage}%') + ax.axvline(x=float(x_pos), linestyle="--", c=color, label=f"{percentage}%") draw_threshold(1 / 3, "tab:green") draw_threshold(0.5, "tab:olive") @@ -213,7 +203,7 @@ def draw_threshold(threshold: float, color: str) -> None: e.set_image(url="attachment://graph.png") f = File(img, filename="graph.png") - await ctx.send(embed=e, files=[f]) + await interaction.followup.send(embed=e, files=[f]) img.close() diff --git a/rocketwatch/plugins/minipool_task/minipool_task.py b/rocketwatch/plugins/minipool_task/minipool_task.py deleted file mode 100644 index 0fd4fd32..00000000 --- a/rocketwatch/plugins/minipool_task/minipool_task.py +++ /dev/null @@ -1,179 +0,0 @@ -import asyncio -import copy -import logging -import time -from concurrent.futures import ThreadPoolExecutor - -from cronitor import Monitor -from pymongo import MongoClient -from requests.exceptions import HTTPError -from eth_typing import ChecksumAddress - -from discord.ext import commands, tasks -from discord.utils import as_chunks - -from rocketwatch import RocketWatch -from utils.cfg import cfg -from utils.rocketpool import rp -from utils.shared_w3 import w3, bacon -from utils.solidity import to_float -from utils.time_debug import timerun - -log = logging.getLogger("minipool_task") -log.setLevel(cfg["log_level"]) - - -class MinipoolTask(commands.Cog): - def __init__(self, bot: RocketWatch): - self.bot = bot - self.db = MongoClient(cfg["mongodb.uri"]).rocketwatch - self.minipool_manager = rp.get_contract_by_name("rocketMinipoolManager") - self.monitor = Monitor('gather-minipools', api_key=cfg["other.secrets.cronitor"]) - self.batch_size = 1000 - self.loop.start() - - def cog_unload(self): - self.loop.cancel() - - @tasks.loop(seconds=60 ** 2) - async def loop(self): - p_id = time.time() - self.monitor.ping(state='run', series=p_id) - executor = ThreadPoolExecutor() - loop = asyncio.get_event_loop() - futures = [loop.run_in_executor(executor, self.task)] - try: - await asyncio.gather(*futures) - self.monitor.ping(state="complete", series=p_id) - except Exception as err: - await self.bot.report_error(err) - self.monitor.ping(state="fail", series=p_id) - - @loop.before_loop - async def before_loop(self): - await self.bot.wait_until_ready() - - @timerun - def get_untracked_minipools(self) -> set[ChecksumAddress]: - minipool_count = rp.call("rocketMinipoolManager.getMinipoolCount") - minipool_addresses = [] - for i in range(0, minipool_count, self.batch_size): - log.debug(f"getting minipool addresses for {i}/{minipool_count}") - i_end = min(i + self.batch_size, minipool_count) - minipool_addresses += [ - w3.toChecksumAddress(r.results[0]) for r in rp.multicall.aggregate( - self.minipool_manager.functions.getMinipoolAt(i) for i in range(i, i_end)).results] - # remove address that are already in the minipool collection - tracked_addresses = self.db.minipools.distinct("address") - return set(minipool_addresses) - set(tracked_addresses) - - @timerun - def get_public_keys(self, addresses): - # optimizing this doesn't seem to help much, so keep it simple for readability - # batch the same way as get_untracked_minipools - minipool_pubkeys = [] - for i in range(0, len(addresses), self.batch_size): - log.debug(f"getting minipool pubkeys for {i}/{len(addresses)}") - i_end = min(i + self.batch_size, len(addresses)) - minipool_pubkeys += [ - f"0x{r.results[0].hex()}" for r in rp.multicall.aggregate( - self.minipool_manager.functions.getMinipoolPubkey(a) for a in addresses[i:i_end]).results] - return minipool_pubkeys - - @timerun - def get_node_operator(self, addresses): - base_contract = rp.assemble_contract("rocketMinipool", w3.toChecksumAddress(addresses[0])) - func = base_contract.functions.getNodeAddress() - minipool_contracts = [] - for a in addresses: - tmp = copy.deepcopy(func) - tmp.address = w3.toChecksumAddress(a) - minipool_contracts.append(tmp) - node_addresses = rp.multicall.aggregate(minipool_contracts) - node_addresses = [w3.toChecksumAddress(r.results[0]) for r in node_addresses.results] - return node_addresses - - @timerun - def get_node_fee(self, addresses): - base_contract = rp.assemble_contract("rocketMinipool", w3.toChecksumAddress(addresses[0])) - func = base_contract.functions.getNodeFee() - minipool_contracts = [] - for a in addresses: - tmp = copy.deepcopy(func) - tmp.address = w3.toChecksumAddress(a) - minipool_contracts.append(tmp) - node_fees = rp.multicall.aggregate(minipool_contracts) - node_fees = [to_float(r.results[0]) for r in node_fees.results] - return node_fees - - @timerun - def get_validator_data(self, pubkeys): - result = {} - pubkeys_divisor = max(len(pubkeys) // 10, 1) # Make sure divisor is at least 1 to avoid division by zero - for i, pubkey in enumerate(pubkeys): - if i % pubkeys_divisor == 0: - log.debug(f"getting validator data for {i}/{len(pubkeys)}") - try: - data = bacon.get_validator(validator_id=pubkey, state_id="finalized") - except HTTPError: - continue - data = data["data"] - validator_id = int(data["index"]) - activation_epoch = int(data["validator"]["activation_epoch"]) - # The activation epoch is set to the possible maximum int if none has been determined yet. - # I don't check for an exact value because it turns out that nimbus uses uint64 while Teku uses int64. - # >=2**23 will be good enough for the next 100 years, after which neither this bot nor its creator will be alive. - if activation_epoch >= 2 ** 23: - continue - result[pubkey] = {"validator_id": validator_id, "activation_epoch": activation_epoch} - return result - - def check_indexes(self): - log.debug("checking indexes") - self.db.proposals.create_index("validator") - # self.db.minipools.create_index("validator", unique=True) - # remove the old unique validator index if it exists, create a new one without unique called validator_2 - if "validator_1" in self.db.minipools.index_information(): - self.db.minipools.drop_index("validator_1") - self.db.minipools.create_index("validator", name="validator_2") - self.db.proposals.create_index("slot", unique=True) - self.db.minipools.create_index("address") - log.debug("indexes checked") - - def task(self): - self.check_indexes() - log.debug("Gathering all untracked minipools...") - all_minipool_addresses = self.get_untracked_minipools() - if not all_minipool_addresses: - log.debug("No untracked minipools found.") - return - - log.debug(f"Found {len(all_minipool_addresses)} untracked minipools.") - for minipool_addresses in as_chunks(all_minipool_addresses, self.batch_size): - log.debug("Gathering minipool public keys...") - minipool_pubkeys = self.get_public_keys(minipool_addresses) - log.debug("Gathering minipool node operators...") - node_addresses = self.get_node_operator(minipool_addresses) - log.debug("Gathering minipool commission rates...") - node_fees = self.get_node_fee(minipool_addresses) - log.debug("Gathering minipool validator indexes...") - validator_data = self.get_validator_data(minipool_pubkeys) - data = [{ - "address" : a, - "pubkey" : p, - "node_operator" : n, - "node_fee" : f, - "validator" : validator_data[p]["validator_id"], - "activation_epoch": validator_data[p]["activation_epoch"] - } for a, p, n, f in zip(minipool_addresses, minipool_pubkeys, node_addresses, node_fees) if p in validator_data] - if data: - log.debug(f"Inserting {len(data)} minipools into the database...") - self.db.minipools.insert_many(data) - else: - log.debug("No new minipools with data found.") - - log.debug("Finished!") - - -async def setup(bot): - await bot.add_cog(MinipoolTask(bot)) diff --git a/rocketwatch/plugins/minipools_upkeep_task/minipools_upkeep_task.py b/rocketwatch/plugins/minipools_upkeep_task/minipools_upkeep_task.py deleted file mode 100644 index 246177e5..00000000 --- a/rocketwatch/plugins/minipools_upkeep_task/minipools_upkeep_task.py +++ /dev/null @@ -1,142 +0,0 @@ -import asyncio -import logging -from concurrent.futures import ThreadPoolExecutor - -import pymongo -from discord.ext import commands, tasks -from discord.ext.commands import hybrid_command -from motor.motor_asyncio import AsyncIOMotorClient -from multicall import Call - -from rocketwatch import RocketWatch -from utils import solidity -from utils.embeds import Embed, el_explorer_url -from utils.readable import s_hex -from utils.shared_w3 import w3 -from utils.visibility import is_hidden -from utils.cfg import cfg -from utils.rocketpool import rp -from utils.time_debug import timerun_async - -log = logging.getLogger("minipools_upkeep_task") -log.setLevel(cfg["log_level"]) - - -def div_32(i: int): - return solidity.to_float(i) / 32 - -class MinipoolsUpkeepTask(commands.Cog): - def __init__(self, bot: RocketWatch): - self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch - self.loop.start() - - def cog_unload(self): - self.loop.cancel() - - # every 6.4 minutes - @tasks.loop(seconds=solidity.BEACON_EPOCH_LENGTH) - async def loop(self): - try: - await self.upkeep_minipools() - except Exception as err: - await self.bot.report_error(err) - - @loop.before_loop - async def on_ready(self): - await self.bot.wait_until_ready() - - @timerun_async - async def get_minipool_stats(self, minipools): - m_d = rp.get_contract_by_name("rocketMinipoolDelegate") - m = rp.assemble_contract("rocketMinipool", address=minipools[0]) - mc = rp.get_contract_by_name("multicall3") - lambs = [ - lambda x: (x, rp.seth_sig(m_d.abi, "getNodeFee"), [((x, "NodeFee"), solidity.to_float)]), - lambda x: (x, rp.seth_sig(m.abi, "getEffectiveDelegate"), [((x, "Delegate"), None)]), - lambda x: (x, rp.seth_sig(m.abi, "getPreviousDelegate"), [((x, "PreviousDelegate"), None)]), - lambda x: (x, rp.seth_sig(m.abi, "getUseLatestDelegate"), [((x, "UseLatestDelegate"), None)]), - lambda x: (x, rp.seth_sig(m.abi, "getNodeDepositBalance"), [((x, "NodeOperatorShare"), div_32)]), - # get balances of minipool as well - lambda x: (mc.address, [rp.seth_sig(mc.abi, "getEthBalance"), x], [((x, "EthBalance"), solidity.to_float)]) - ] - minipool_stats = {} - batch_size = 10_000 // len(lambs) - for i in range(0, len(minipools), batch_size): - i_end = min(i + batch_size, len(minipools)) - log.debug(f"getting minipool stats for {i}-{i_end}") - addresses = minipools[i:i_end] - calls = [ - Call(*lamb(a)) - for a in addresses - for lamb in lambs - ] - res = await rp.multicall2(calls) - # add data to mini pool stats dict (address => {func_name: value}) - # strip get from function name - for (address, variable_name), value in res.items(): - if address not in minipool_stats: - minipool_stats[address] = {} - minipool_stats[address][variable_name] = value - return minipool_stats - - async def upkeep_minipools(self): - logging.info("Updating minipool states") - a = await self.db.minipools.find().distinct("address") - b = await self.get_minipool_stats(a) - # update data in db using unordered bulk write - # note: this data is kept in the "meta" field of each minipool - bulk = [ - pymongo.UpdateOne( - {"address": address}, - {"$set": {"meta": stats}}, - upsert=True - ) for address, stats in b.items() - ] - - await self.db.minipools.bulk_write(bulk, ordered=False) - logging.info("Updated minipool states") - - @hybrid_command() - async def delegate_stats(self, ctx): - await ctx.defer(ephemeral=is_hidden(ctx)) - # get stats about delegates - # we want to show the distribution of minipools that are using each delegate - distribution_stats = await self.db.minipools_new.aggregate([ - {"$match": {"effective_delegate": {"$exists": True}}}, - {"$group": {"_id": "$effective_delegate", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - ]).to_list(None) - # and the percentage of minipools that are using the useLatestDelegate flag - use_latest_delegate_stats = await self.db.minipools_new .aggregate([ - {"$match": {"use_latest_delegate": {"$exists": True}}}, - {"$group": {"_id": "$use_latest_delegate", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - ]).to_list(None) - e = Embed() - e.title = "Delegate Stats" - desc = "**Effective Delegate Distribution of Minipools:**\n" - c_sum = sum(d['count'] for d in distribution_stats) - s = "\u00A0" * 4 - # latest delegate acording to rp - rp.uncached_get_address_by_name("rocketMinipoolDelegate") - for d in distribution_stats: - # I HATE THE CHECKSUMMED ADDRESS REQUIREMENTS I HATE THEM SO MUCH - a = w3.toChecksumAddress(d['_id']) - name = s_hex(a) - if a == rp.get_address_by_name("rocketMinipoolDelegate"): - name += " (Latest)" - desc += f"{s}{el_explorer_url(a, name)}: {d['count']} ({d['count'] / c_sum * 100:.2f}%)\n" - desc += "\n" - desc += "**Minipools configured to always use latest delegate:**\n" - c_sum = sum(d['count'] for d in use_latest_delegate_stats) - for d in use_latest_delegate_stats: - # true = yes, false = no - d['_id'] = "Yes" if d['_id'] else "No" - desc += f"{s}**{d['_id']}**: {d['count']} ({d['count'] / c_sum * 100:.2f}%)\n" - e.description = desc - await ctx.send(embed=e) - - -async def setup(self): - await self.add_cog(MinipoolsUpkeepTask(self)) diff --git a/rocketwatch/plugins/node_fee_distribution/node_fee_distribution.py b/rocketwatch/plugins/node_fee_distribution/node_fee_distribution.py deleted file mode 100644 index 1e26a08b..00000000 --- a/rocketwatch/plugins/node_fee_distribution/node_fee_distribution.py +++ /dev/null @@ -1,73 +0,0 @@ -import logging - -import numpy as np -from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command - -from rocketwatch import RocketWatch -from utils.cfg import cfg -from utils.embeds import Embed -from utils.etherscan import get_recent_account_transactions -from utils.rocketpool import rp -from utils.visibility import is_hidden - -log = logging.getLogger("node_fee_distribution") -log.setLevel(cfg["log_level"]) - - -def get_percentiles(percentiles, values): - return {p: np.percentile(values, p, interpolation='nearest') for p in percentiles} - - -class NodeFeeDistribution(commands.Cog): - PERCENTILES = [1, 10, 25, 50, 75, 90, 99] - - def __init__(self, bot: RocketWatch): - self.bot = bot - - @hybrid_command() - async def node_fee_distribution(self, ctx: Context): - """ - Show the distribution of node expenses due to gas fees. - """ - await ctx.defer(ephemeral=is_hidden(ctx)) - - e = Embed() - e.title = "Node Fee Distributions" - e.description = "" - - deposit_txs = await get_recent_account_transactions( - rp.get_address_by_name("rocketNodeDeposit")) - rpl_staking_txs = await get_recent_account_transactions( - rp.get_address_by_name("rocketNodeStaking")) - first = True - - for title, txs in [('Minipool Deposit', deposit_txs), ('RPL Staking', rpl_staking_txs)]: - if not first: - e.description += "\n" - else: - first = False - - if len(txs) > 0: - since = min([int(x["timeStamp"]) for x in txs.values()]) - gas = [int(x["gasPrice"]) // int(1E9) for x in txs.values()] - totals = [int(x["gasUsed"]) * int(x["gasPrice"]) / - 1E18 for x in txs.values()] - gas_percentiles = get_percentiles(NodeFeeDistribution.PERCENTILES, gas) - fee_percentiles = get_percentiles(NodeFeeDistribution.PERCENTILES, totals) - - e.description += f"**{title} Fees:**\n" - e.description += f"_Since _\n```" - e.description += f"Minimum: {min(gas)} gwei gas, {min(totals):.4f} eth total\n" - for p in NodeFeeDistribution.PERCENTILES: - e.description += f"{str(p):>2}th percentile: {int(gas_percentiles[p]):>4} gwei gas, {fee_percentiles[p]:.4f} eth total\n" - e.description += f"Maximum: {max(gas)} gwei gas, {max(totals):.4f} eth total```\n" - else: - e.description += f"No recent {title} transactions found.\n" - - await ctx.send(embed=e) - - -async def setup(bot): - await bot.add_cog(NodeFeeDistribution(bot)) diff --git a/rocketwatch/plugins/node_task/node_task.py b/rocketwatch/plugins/node_task/node_task.py deleted file mode 100644 index 0d95c4cb..00000000 --- a/rocketwatch/plugins/node_task/node_task.py +++ /dev/null @@ -1,498 +0,0 @@ -import logging -import time - -import pymongo -from multicall import Call -from cronitor import Monitor -from pymongo import UpdateOne, UpdateMany - -from discord.ext import tasks, commands - -from rocketwatch import RocketWatch -from utils import solidity -from utils.cfg import cfg -from utils.block_time import ts_to_block -from utils.rocketpool import rp -from utils.shared_w3 import bacon -from utils.time_debug import timerun, timerun_async -from utils.event_logs import get_logs - - -log = logging.getLogger("node_task") -log.setLevel(cfg["log_level"]) - - -def safe_to_float(_, num: int): - try: - return solidity.to_float(num) - except Exception: - return None - -def safe_to_hex(_, b: bytes): - return f"0x{b.hex()}" if b else None - -def safe_state_to_str(_, state: int): - try: - return solidity.mp_state_to_str(state) - except Exception: - return None - -def safe_inv(_, num: int): - try: - return 1 / solidity.to_float(num) - except Exception: - return None - -def is_true(_, b): - return b is True - - -class NodeTask(commands.Cog): - def __init__(self, bot: RocketWatch): - self.bot = bot - self.db = pymongo.MongoClient(cfg["mongodb.uri"]).rocketwatch - self.monitor = Monitor("node-task", api_key=cfg["other.secrets.cronitor"]) - self.batch_size = 1000 - self.loop.start() - - def cog_unload(self): - self.loop.cancel() - - @tasks.loop(seconds=solidity.BEACON_EPOCH_LENGTH) - async def loop(self): - p_id = time.time() - self.monitor.ping(state="run", series=p_id) - try: - log.debug("starting node task") - self.check_indexes() - await self.add_untracked_minipools() - await self.add_static_data_to_minipools() - await self.update_dynamic_minipool_metadata() - self.add_static_deposit_data_to_minipools() - self.add_static_beacon_data_to_minipools() - self.update_dynamic_minipool_beacon_metadata() - await self.add_untracked_node_operators() - await self.add_static_data_to_node_operators() - await self.update_dynamic_node_operator_metadata() - log.debug("node task finished") - self.monitor.ping(state="complete", series=p_id) - except Exception as err: - await self.bot.report_error(err) - self.monitor.ping(state="fail", series=p_id) - - @loop.before_loop - async def on_ready(self): - await self.bot.wait_until_ready() - - @timerun_async - async def add_untracked_minipools(self): - # rocketMinipoolManager.getMinipoolAt(i) returns the address of the minipool at index i - mm = rp.get_contract_by_name("rocketMinipoolManager") - latest_rp = rp.call("rocketMinipoolManager.getMinipoolCount") - 1 - # get latest _id in minipools_new collection - latest_db = 0 - if res := self.db.minipools_new.find_one(sort=[("_id", pymongo.DESCENDING)]): - latest_db = res["_id"] - data = {} - # return early if we're up to date - if latest_db == latest_rp: - log.debug("No new minipools") - return - log.debug(f"Latest minipool in db: {latest_db}, latest minipool in rp: {latest_rp}") - # batch into self.batch_size minipools at a time, between latest_id and minipool_count - for i in range(latest_db + 1, latest_rp + 1, self.batch_size): - i_end = min(i + self.batch_size, latest_rp + 1) - log.debug(f"Getting untracked minipools ({i} to {i_end})") - data |= await rp.multicall2([ - Call(mm.address, [rp.seth_sig(mm.abi, "getMinipoolAt"), i], [(i, None)]) - for i in range(i, i_end) - ]) - log.debug(f"Inserting {len(data)} new minipools into db") - self.db.minipools_new.insert_many([ - {"_id": i, "address": a} - for i, a in data.items() - ]) - log.debug("New minipools inserted") - - @timerun_async - async def add_static_data_to_minipools(self): - m = rp.assemble_contract("rocketMinipool") - mm = rp.get_contract_by_name("rocketMinipoolManager") - lambs = [ - lambda a: (a, rp.seth_sig(m.abi, "getNodeAddress"), [((a, "node_operator"), None)]), - lambda a: (mm.address, [rp.seth_sig(mm.abi, "getMinipoolPubkey"), a], [((a, "pubkey"), safe_to_hex)]), - ] - # get all minipool addresses from db that do not have a node operator assigned - minipool_addresses = self.db.minipools_new.distinct("address", {"node_operator": {"$exists": False}}) - # get node operator addresses from rp - # return early if no minipools need to be updated - if not minipool_addresses: - log.debug("No minipools need to be updated with static data") - return - data = {} - batch_size = self.batch_size // len(lambs) - for i in range(0, len(minipool_addresses), batch_size): - i_end = min(i + batch_size, len(minipool_addresses)) - log.debug(f"Getting minipool static data ({i} to {i_end})") - res = await rp.multicall2([ - Call(*lamb(a)) - for a in minipool_addresses[i:i_end] - for lamb in lambs - ], require_success=False) - # update data dict with results - for (address, variable_name), value in res.items(): - if address not in data: - data[address] = {} - data[address][variable_name] = value - log.debug(f"Updating {len(data)} minipools with static data") - # update minipools in db - bulk = [ - UpdateOne( - {"address": a}, - {"$set": d}, - ) for a, d in data.items() - ] - self.db.minipools_new.bulk_write(bulk, ordered=False) - log.debug("Minipools updated with static data") - - @timerun_async - async def update_dynamic_minipool_metadata(self): - m = rp.assemble_contract("rocketMinipool") - mc = rp.get_contract_by_name("multicall3") - lambs = [ - lambda a: (a, rp.seth_sig(m.abi, "getStatus"), [((a, "status"), safe_state_to_str)]), - lambda a: (a, rp.seth_sig(m.abi, "getStatusTime"), [((a, "status_time"), None)]), - lambda a: (a, rp.seth_sig(m.abi, "getVacant"), [((a, "vacant"), is_true)]), - lambda a: (a, rp.seth_sig(m.abi, "getNodeDepositBalance"), [((a, "node_deposit_balance"), safe_to_float)]), - lambda a: (a, rp.seth_sig(m.abi, "getNodeRefundBalance"), [((a, "node_refund_balance"), safe_to_float)]), - lambda a: (a, rp.seth_sig(m.abi, "getPreMigrationBalance"), [((a, "pre_migration_balance"), safe_to_float)]), - lambda a: (a, rp.seth_sig(m.abi, "getNodeFee"), [((a, "node_fee"), safe_to_float)]), - lambda a: (a, rp.seth_sig(m.abi, "getEffectiveDelegate"), [((a, "effective_delegate"), None)]), - lambda a: (a, rp.seth_sig(m.abi, "getUseLatestDelegate"), [((a, "use_latest_delegate"), None)]), - lambda a: (mc.address, [rp.seth_sig(mc.abi, "getEthBalance"), a], [((a, "execution_balance"), safe_to_float)]) - ] - # get all minipool addresses from db - minipool_addresses = self.db.minipools_new.distinct("address") - data = {} - att_count = 0 - batch_size = self.batch_size // len(lambs) - for i in range(0, len(minipool_addresses), batch_size): - i_end = min(i + batch_size, len(minipool_addresses)) - log.debug(f"Getting minipool metadata ({i} to {i_end})") - res = await rp.multicall2([ - Call(*lamb(a)) - for a in minipool_addresses[i:i_end] - for lamb in lambs - ], require_success=False) - # update data dict with results - for (address, variable_name), value in res.items(): - if address not in data: - data[address] = {} - data[address][variable_name] = value - att_count += 1 - log.debug(f"Updating {att_count} minipool attributes in db") - # update minipools in db - bulk = [ - UpdateOne( - {"address": a}, - {"$set": d} - ) for a, d in data.items() - ] - self.db.minipools_new.bulk_write(bulk, ordered=False) - log.debug("Minipools updated with metadata") - return - - @timerun - def add_static_deposit_data_to_minipools(self): - # get all minipool addresses and their status time from db that : - # - do not have a deposit_amount - # - are in the initialised state - # sort by status time - minipools = list(self.db.minipools_new.find( - {"deposit_amount": {"$exists": False}, "status": "initialised"}, - {"address": 1, "_id": 0, "status_time": 1} - ).sort("status_time", pymongo.ASCENDING)) - # return early if no minipools need to be updated - if not minipools: - log.debug("No minipools need to be updated with static deposit data") - return - nd = rp.get_contract_by_name("rocketNodeDeposit") - mm = rp.get_contract_by_name("rocketMinipoolManager") - data = {} - for i in range(0, len(minipools), self.batch_size): - i_end = min(i + self.batch_size, len(minipools)) - # turn status time of first and last minipool into blocks - block_start = ts_to_block(minipools[i]["status_time"]) - 1 - block_end = ts_to_block(minipools[i_end - 1]["status_time"]) + 1 - a = [m["address"] for m in minipools[i:i_end]] - log.debug(f"Getting minipool deposit data ({i} to {i_end})") - - f_deposits = get_logs(nd.events.DepositReceived, block_start, block_end) - f_creations = get_logs(mm.events.MinipoolCreated, block_start, block_end) - events = f_deposits + f_creations - - events = sorted(events, key=lambda x: (x['blockNumber'], x['transactionIndex'], x['logIndex'] *1e-8), reverse=True) - # map to pairs of 2 - prepared_events = [] - last_addition_is_creation = False - while events: - # get event - e = events.pop(0) - if e["event"] == "MinipoolCreated": - if not last_addition_is_creation: - prepared_events.append([e]) - else: - prepared_events[-1] = [e] - log.info(f"replacing creation even with newly found one ({prepared_events[-1]})") - elif e["event"] == "DepositReceived" and last_addition_is_creation: - prepared_events[-1].insert(0, e) - last_addition_is_creation = e["event"] == "MinipoolCreated" - for e in prepared_events: - assert "amount" in e[0]["args"] - assert "minipool" in e[1]["args"] - # assert that the txn hashes match - assert e[0]["transactionHash"] == e[1]["transactionHash"] - mp = str(e[1]["args"]["minipool"]).lower() - if mp not in a: - continue - amount = solidity.to_float(e[0]["args"]["amount"]) - data[mp] = {"deposit_amount": amount} - if len(data) == 0: - log.debug("No minipools need to be updated with static deposit data") - return - log.debug(f"Updating {len(data)} minipools with static deposit data") - # update minipools in db - bulk = [ - UpdateOne( - {"address": a}, - {"$set": d}, - ) for a, d in data.items() - ] - self.db.minipools_new.bulk_write(bulk, ordered=False) - log.debug("Minipools updated with static deposit data") - - - @timerun - def add_static_beacon_data_to_minipools(self): - # get all public keys from db where no validator_index is set - public_keys = self.db.minipools_new.distinct("pubkey", {"validator_index": {"$exists": False}}) - # return early if no minipools need to be updated - if not public_keys: - log.debug("No minipools need to be updated with static beacon data") - return - # we need to do smaller bulks as the pubkey is qutie long and we dont want to make the query url too long - data = {} - # endpoint = bacon.get_validators("head", ids=vali_indexes)["data"] - for i in range(0, len(public_keys), self.batch_size): - i_end = min(i + self.batch_size, len(public_keys)) - log.debug(f"Getting beacon data for minipools ({i} to {i_end})") - # get beacon data for public keys - beacon_data = bacon.get_validators("head", ids=public_keys[i:i_end])["data"] - # update data dict with results - for d in beacon_data: - data[d["validator"]["pubkey"]] = int(d["index"]) - if not data: - log.debug("No minipools need to be updated with static beacon data") - return - log.debug(f"Updating {len(data)} minipools with static beacon data") - # update minipools in db - bulk = [ - UpdateMany( - {"pubkey": a}, - {"$set": {"validator_index": d}} - ) for a, d in data.items() - ] - self.db.minipools_new.bulk_write(bulk, ordered=False) - log.debug("Minipools updated with static beacon data") - - @timerun - def update_dynamic_minipool_beacon_metadata(self): - # basically same ordeal as above, but we use the validator index to get the data to improve performance - # get all validator indexes from db - validator_indexes = self.db.minipools_new.distinct("validator_index") - # remove None values - validator_indexes = [i for i in validator_indexes if i is not None] - data = {} - # endpoint = bacon.get_validators("head", ids=vali_indexes)["data"] - for i in range(0, len(validator_indexes), self.batch_size): - i_end = min(i + self.batch_size, len(validator_indexes)) - log.debug(f"Getting beacon data for minipools ({i} to {i_end})") - # get beacon data for public keys - beacon_data = bacon.get_validators("head", ids=validator_indexes[i:i_end])["data"] - # update data dict with results - for d in beacon_data: - data[int(d["index"])] = { - "beacon": { - "status" : d["status"], - "balance" : solidity.to_float(d["balance"], 9), - "effective_balance" : solidity.to_float(d["validator"]["effective_balance"], 9), - "slashed" : d["validator"]["slashed"], - "activation_eligibility_epoch": int(d["validator"]["activation_eligibility_epoch"]) if int( - d["validator"]["activation_eligibility_epoch"]) < 2 ** 32 else None, - "activation_epoch" : int(d["validator"]["activation_epoch"]) if int( - d["validator"]["activation_epoch"]) < 2 ** 32 else None, - "exit_epoch" : int(d["validator"]["exit_epoch"]) if int( - d["validator"]["exit_epoch"]) < 2 ** 32 else None, - "withdrawable_epoch" : int(d["validator"]["withdrawable_epoch"]) if int( - d["validator"]["withdrawable_epoch"]) < 2 ** 32 else None, - }} - log.debug(f"Updating {len(data)} minipools with dynamic beacon data") - # update minipools in db - bulk = [ - UpdateMany( - {"validator_index": a}, - {"$set": d} - ) for a, d in data.items() - ] - self.db.minipools_new.bulk_write(bulk, ordered=False) - log.debug("Minipools updated with dynamic beacon data") - - def check_indexes(self): - log.debug("checking indexes") - self.db.minipools_new.create_index("address") - self.db.minipools_new.create_index("pubkey") - self.db.minipools_new.create_index("validator_index") - self.db.node_operators_new.create_index("address") - # proposal index creation that is for some reason here - self.db.proposals.create_index("validator") - self.db.proposals.create_index("validator") - self.db.proposals.create_index("slot", unique=True) - log.debug("indexes checked") - - @timerun_async - async def add_untracked_node_operators(self): - # rocketNodeManager.getNodeCount(i) returns the address of the node at index i - nm = rp.get_contract_by_name("rocketNodeManager") - latest_rp = rp.call("rocketNodeManager.getNodeCount") - 1 - # get latest _id in node_operators_new collection - latest_db = 0 - if res := self.db.node_operators_new.find_one(sort=[("_id", pymongo.DESCENDING)]): - latest_db = res["_id"] - data = {} - # return early if we're up to date - if latest_db == latest_rp: - log.debug("No new nodes") - return - # batch into 10k nodes at a time, between latest_id and latest_rp - for i in range(latest_db + 1, latest_rp + 1, self.batch_size): - i_end = min(i + self.batch_size, latest_rp + 1) - log.debug(f"Getting untracked node ({i} to {i_end})") - data |= await rp.multicall2([ - Call(nm.address, [rp.seth_sig(nm.abi, "getNodeAt"), i], [(i, None)]) - for i in range(i, i_end) - ]) - log.debug(f"Inserting {len(data)} new nodes into db") - self.db.node_operators_new.insert_many([ - {"_id": i, "address": a} - for i, a in data.items() - ]) - log.debug("New nodes inserted") - - @timerun_async - async def add_static_data_to_node_operators(self): - ndf = rp.get_contract_by_name("rocketNodeDistributorFactory") - lambs = [ - lambda a: (ndf.address, [rp.seth_sig(ndf.abi, "getProxyAddress"), a], [((a, "fee_distributor_address"), None)]), - ] - # get all minipool addresses from db that do not have a node operator assigned - node_addresses = self.db.node_operators_new.distinct("address", {"fee_distributor_address": {"$exists": False}}) - # get node operator addresses from rp - # return early if no minipools need to be updated - if not node_addresses: - log.debug("No node operators need to be updated with static data") - return - data = {} - batch_size = self.batch_size // len(lambs) - for i in range(0, len(node_addresses), batch_size): - i_end = min(i + batch_size, len(node_addresses)) - log.debug(f"Getting node operators static data ({i} to {i_end})") - res = await rp.multicall2([ - Call(*lamb(a)) - for a in node_addresses[i:i_end] - for lamb in lambs - ], require_success=False) - # update data dict with results - for (address, variable_name), value in res.items(): - if address not in data: - data[address] = {} - data[address][variable_name] = value - log.debug(f"Updating {len(data)} node operators with static data") - # update minipools in db - bulk = [ - UpdateOne( - {"address": a}, - {"$set": d}, - ) for a, d in data.items() - ] - self.db.node_operators_new.bulk_write(bulk, ordered=False) - log.debug("Node operators updated with static data") - - @timerun_async - async def update_dynamic_node_operator_metadata(self): - ndf = rp.get_contract_by_name("rocketNodeDistributorFactory") - nd = rp.get_contract_by_name("rocketNodeDeposit") - nm = rp.get_contract_by_name("rocketNodeManager") - mm = rp.get_contract_by_name("rocketMinipoolManager") - ns = rp.get_contract_by_name("rocketNodeStaking") - mc = rp.get_contract_by_name("multicall3") - lambs = [ - lambda n: (ndf.address, [rp.seth_sig(ndf.abi, "getProxyAddress"), n["address"]], - [((n["address"], "fee_distributor_address"), None)]), - lambda n: (nm.address, [rp.seth_sig(nm.abi, "getNodeWithdrawalAddress"), n["address"]], - [((n["address"], "withdrawal_address"), None)]), - lambda n: (nm.address, [rp.seth_sig(nm.abi, "getNodeTimezoneLocation"), n["address"]], - [((n["address"], "timezone_location"), None)]), - lambda n: (nm.address, [rp.seth_sig(nm.abi, "getFeeDistributorInitialised"), n["address"]], - [((n["address"], "fee_distributor_initialised"), None)]), - lambda n: ( - nm.address, [rp.seth_sig(nm.abi, "getRewardNetwork"), n["address"]], - [((n["address"], "reward_network"), None)]), - lambda n: (nm.address, [rp.seth_sig(nm.abi, "getSmoothingPoolRegistrationState"), n["address"]], - [((n["address"], "smoothing_pool_registration_state"), None)]), - lambda n: (nm.address, [rp.seth_sig(nm.abi, "getAverageNodeFee"), n["address"]], - [((n["address"], "average_node_fee"), safe_to_float)]), - lambda n: (ns.address, [rp.seth_sig(ns.abi, "getNodeRPLStake"), n["address"]], - [((n["address"], "rpl_stake"), safe_to_float)]), - lambda n: (ns.address, [rp.seth_sig(ns.abi, "getNodeEffectiveRPLStake"), n["address"]], - [((n["address"], "effective_rpl_stake"), safe_to_float)]), - lambda n: (ns.address, [rp.seth_sig(ns.abi, "getNodeETHCollateralisationRatio"), n["address"]], - [((n["address"], "effective_node_share"), safe_inv)]), - lambda n: (mc.address, [rp.seth_sig(mc.abi, "getEthBalance"), n["fee_distributor_address"]], - [((n["address"], "fee_distributor_eth_balance"), safe_to_float)]), - lambda n: (mm.address, [rp.seth_sig(mm.abi, "getNodeStakingMinipoolCount"), n["address"]], - [((n["address"], "staking_minipool_count"), None)]), - lambda n: (nd.address, [rp.seth_sig(nd.abi, "getNodeDepositCredit"), n["address"]], - [((n["address"], "deposit_credit"), safe_to_float)]) - ] - # get all node operators from db, but we only care about the address and the fee_distributor_address - nodes = list(self.db.node_operators_new.find({}, {"address": 1, "fee_distributor_address": 1})) - data = {} - att_count = 0 - batch_size = self.batch_size // len(lambs) - for i in range(0, len(nodes), batch_size): - i_end = min(i + batch_size, len(nodes)) - log.debug(f"Getting node operator metadata ({i} to {i_end})") - res = await rp.multicall2([ - Call(*lamb(n)) - for n in nodes[i:i_end] - for lamb in lambs - ], require_success=False) - # update data dict with results - for (address, variable_name), value in res.items(): - if address not in data: - data[address] = {} - data[address][variable_name] = value - att_count += 1 - log.debug(f"Updating {att_count} node operator attributes in db") - # update minipools in db - bulk = [ - UpdateOne( - {"address": a}, - {"$set": d} - ) for a, d in data.items() - ] - self.db.node_operators_new.bulk_write(bulk, ordered=False) - log.debug("Node operators updated with metadata") - -async def setup(self): - await self.add_cog(NodeTask(self)) diff --git a/rocketwatch/plugins/pinned_messages/pinned_messages.py b/rocketwatch/plugins/pinned_messages/pinned_messages.py index a7cad1fe..6a387941 100644 --- a/rocketwatch/plugins/pinned_messages/pinned_messages.py +++ b/rocketwatch/plugins/pinned_messages/pinned_messages.py @@ -1,24 +1,22 @@ import logging -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta -from motor.motor_asyncio import AsyncIOMotorClient -from discord import Object -from discord.app_commands import guilds +from discord import Interaction +from discord.abc import Messageable +from discord.app_commands import command, guilds from discord.ext import commands, tasks -from discord.ext.commands import hybrid_command, is_owner +from discord.ext.commands import is_owner from rocketwatch import RocketWatch -from utils.cfg import cfg +from utils.config import cfg from utils.embeds import Embed -log = logging.getLogger("rich_activity") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.rich_activity") class PinnedMessages(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch if not self.run_loop.is_running() and bot.is_ready(): self.run_loop.start() @@ -32,29 +30,42 @@ async def on_ready(self): @tasks.loop(seconds=60.0) async def run_loop(self): # get all pinned messages in db - messages = await self.db.pinned_messages.find().to_list(length=None) + messages = await self.bot.db.pinned_messages.find().to_list() for message in messages: # if it's older than 6 hours and not disabled, mark as disabled - if message["created_at"] + timedelta(hours=6) < datetime.utcnow() and not message["disabled"]: - await self.db.pinned_messages.update_one({"_id": message["_id"]}, {"$set": {"disabled": True}}) + if ( + message["created_at"] + timedelta(hours=6) < datetime.now(UTC) + and not message["disabled"] + ): + await self.bot.db.pinned_messages.update_one( + {"_id": message["_id"]}, {"$set": {"disabled": True}} + ) message["disabled"] = True try: # check if it's marked as disabled but not cleaned_up if message["disabled"] and not message["cleaned_up"]: # get channel - channel = self.bot.get_channel(message["channel_id"]) + channel = await self.bot.get_or_fetch_channel(message["channel_id"]) + if not isinstance(channel, Messageable): + continue # get message msg = await channel.fetch_message(message["message_id"]) # delete message await msg.delete() # mark as cleaned_up - await self.db.pinned_messages.update_one({"_id": message["_id"]}, {"$set": {"cleaned_up": True}}) + await self.bot.db.pinned_messages.update_one( + {"_id": message["_id"]}, {"$set": {"cleaned_up": True}} + ) elif not message["disabled"]: # delete and resend message - channel = self.bot.get_channel(message["channel_id"]) + channel = await self.bot.get_or_fetch_channel(message["channel_id"]) + if not isinstance(channel, Messageable): + continue # check if we have message sent already and if its the latest message in the channel - if "message_id" in message and message["message_id"]: - messages = [message async for message in channel.history(limit=5)] + if message.get("message_id"): + messages = [ + message async for message in channel.history(limit=5) + ] # if it isnt within the last 5 messages, we need to resend it if any(m.id == message["message_id"] for m in messages): continue @@ -63,64 +74,93 @@ async def run_loop(self): e = Embed() e.title = message["title"] e.description = message["content"] - e.set_footer(text="This message has been pinned by Invis. Will be automatically removed if not updated within 6 hours.") + e.set_footer( + text=( + "This message has been pinned by Invis." + " Will be automatically removed if not updated within 6 hours." + ) + ) m = await channel.send(embed=e) - await self.db.pinned_messages.update_one({"_id": message["_id"]}, {"$set": {"message_id": m.id}}) + await self.bot.db.pinned_messages.update_one( + {"_id": message["_id"]}, {"$set": {"message_id": m.id}} + ) except Exception as err: await self.bot.report_error(err) - @hybrid_command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @command() + @guilds(cfg.discord.owner.server_id) @is_owner() - async def pin(self, ctx, channel_id, title, description): - await ctx.defer() + async def pin( + self, interaction: Interaction, channel_id: int, title: str, description: str + ): + await interaction.response.defer() # check if channel exists - channel = self.bot.get_channel(int(channel_id)) + channel = self.bot.get_channel(channel_id) if not channel: - await ctx.send("Channel not found") + await interaction.followup.send("Channel not found") return # check if we already have a pinned message - message = await self.db.pinned_messages.find_one({"channel_id": channel.id}) + message = await self.bot.db.pinned_messages.find_one({"channel_id": channel.id}) if message: # update message - await self.db.pinned_messages.update_one({"_id": message["_id"]}, { - "$set": {"title" : title, "content": description, "disabled": False, "cleaned_up": False, - "message_id": None, "created_at": datetime.utcnow()}}) + await self.bot.db.pinned_messages.update_one( + {"_id": message["_id"]}, + { + "$set": { + "title": title, + "content": description, + "disabled": False, + "cleaned_up": False, + "message_id": None, + "created_at": datetime.now(UTC), + } + }, + ) # rest is done by the run_loop - await ctx.send("Updated pinned message") + await interaction.followup.send("Updated pinned message") return # create new message - await self.db.pinned_messages.insert_one( - {"channel_id": channel.id, "message_id": None, "title": title, "content": description, "disabled": False, - "cleaned_up": False, "created_at": datetime.utcnow()}) + await self.bot.db.pinned_messages.insert_one( + { + "channel_id": channel.id, + "message_id": None, + "title": title, + "content": description, + "disabled": False, + "cleaned_up": False, + "created_at": datetime.now(UTC), + } + ) # rest is done by the run_loop - await ctx.send("Created pinned message") + await interaction.followup.send("Created pinned message") - @hybrid_command() - @guilds(Object(id=cfg["discord.owner.server_id"])) + @command() + @guilds(cfg.discord.owner.server_id) @is_owner() - async def unpin(self, ctx, channel_id): - await ctx.defer() + async def unpin(self, interaction: Interaction, channel_id: str): + await interaction.response.defer() # check if channel exists channel = self.bot.get_channel(int(channel_id)) if not channel: - await ctx.send("Channel not found") + await interaction.followup.send("Channel not found") return # check if we already have a pinned message - message = await self.db.pinned_messages.find_one({"channel_id": channel.id}) + message = await self.bot.db.pinned_messages.find_one({"channel_id": channel.id}) if not message: - await ctx.send("No pinned message found") + await interaction.followup.send("No pinned message found") return # check if its already marked as disabled if message["disabled"]: - await ctx.send("Pinned message already disabled") + await interaction.followup.send("Pinned message already disabled") return # soft delete - await self.db.pinned_messages.update_one({"_id": message["_id"]}, {"$set": {"disabled": True}}) + await self.bot.db.pinned_messages.update_one( + {"_id": message["_id"]}, {"$set": {"disabled": True}} + ) # rest is done by the run_loop - await ctx.send("Disabled pinned message") + await interaction.followup.send("Disabled pinned message") - def cog_unload(self): + async def cog_unload(self): self.run_loop.cancel() diff --git a/rocketwatch/plugins/proposals/proposals.py b/rocketwatch/plugins/proposals/proposals.py index f29faa87..1e7fa808 100644 --- a/rocketwatch/plugins/proposals/proposals.py +++ b/rocketwatch/plugins/proposals/proposals.py @@ -1,31 +1,30 @@ +import asyncio import logging import re import time from datetime import datetime, timedelta from io import BytesIO -import aiohttp -import matplotlib as mpl import numpy as np -from PIL import Image -from discord import File +from aiohttp.client_exceptions import ClientResponseError +from cronitor import Monitor +from discord import File, Interaction +from discord.app_commands import command, describe from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command +from discord.utils import as_chunks from matplotlib import pyplot as plt -from motor.motor_asyncio import AsyncIOMotorClient -from pymongo import ReplaceOne -from wordcloud import WordCloud +from pymongo import ASCENDING, DESCENDING from rocketwatch import RocketWatch -from utils.cfg import cfg +from utils.config import cfg from utils.embeds import Embed +from utils.shared_w3 import bacon from utils.solidity import beacon_block_to_date, date_to_beacon_block from utils.time_debug import timerun_async from utils.visibility import is_hidden -log = logging.getLogger("proposals") -log.setLevel(cfg["log_level"]) +cog_id = "proposals" +log = logging.getLogger(f"rocketwatch.{cog_id}") LOOKUP = { "consensus": { @@ -33,55 +32,57 @@ "P": "Prysm", "L": "Lighthouse", "T": "Teku", - "S": "Lodestar" + "S": "Lodestar", }, "execution": { - "I": "Infura", - "P": "Pocket", "G": "Geth", "B": "Besu", "N": "Nethermind", - "X": "External" - } + "R": "Reth", + "X": "External", + }, } COLORS = { - "Nimbus" : "#cc9133", - "Prysm" : "#40bfbf", - "Lighthouse" : "#9933cc", - "Teku" : "#3357cc", - "Lodestar" : "#fb5b9d", - - "Infura" : "#ff2f00", - "Pocket" : "#e216e9", - "Geth" : "#40bfbf", - "Besu" : "#55aa7a", - "Nethermind" : "#2688d9", - "External" : "#808080", - - "Smart Node" : "#cc6e33", - "Allnodes" : "#4533cc", + "Nimbus": "#CC9133", + "Prysm": "#40BFBF", + "Lighthouse": "#9933CC", + "Teku": "#3357CC", + "Lodestar": "#FB5B9D", + "Geth": "#40BFBF", + "Besu": "#55AA7A", + "Nethermind": "#2688D9", + "Reth": "#760910", + "External": "#808080", + "Smart Node": "#CC6E33", + "Allnodes": "#4533cc", "No proposals yet": "#E0E0E0", - "Unknown" : "#AAAAAA", + "Unknown": "#AAAAAA", } PROPOSAL_TEMPLATE = { - "type" : "Unknown", + "type": "Unknown", "consensus_client": "Unknown", "execution_client": "Unknown", } # noinspection RegExpUnnecessaryNonCapturingGroup -SMARTNODE_REGEX = re.compile(r"^RP(?:(?:-)([A-Z])([A-Z])?)? (?:v)?(\d+\.\d+\.\d+(?:-\w+)?)(?:(?:(?: \()|(?: gw:))(.+)(?:\)))?") +SMARTNODE_REGEX = re.compile( + r"^RP(?:(?:-)([A-Z])([A-Z])?)? (?:v)?(\d+\.\d+\.\d+(?:-\w+)?)(?:(?:(?: \()|(?: gw:))(.+)(?:\)))?" +) -def parse_propsal(entry): - graffiti = bytes.fromhex(entry["validator"]["graffiti"][2:]).decode("utf-8").rstrip('\x00') +def parse_proposal(beacon_block: dict) -> dict: + graffiti = ( + bytes.fromhex(beacon_block["body"]["graffiti"][2:]) + .decode("utf-8") + .rstrip("\x00") + ) data = { - "slot" : int(entry["number"]), - "validator": int(entry["validator"]["index"]), - "graffiti" : graffiti, - } + "slot": int(beacon_block["slot"]), + "validator": int(beacon_block["proposer_index"]), + "graffiti": graffiti, + } | PROPOSAL_TEMPLATE if m := SMARTNODE_REGEX.findall(graffiti): groups = m[0] # smart node proposal @@ -117,233 +118,251 @@ def parse_propsal(entry): class Proposals(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.rocketscan_proposals_url = "https://rocketscan.io/api/mainnet/beacon/blocks/all" - self.last_chore_run = 0 - # connect to local mongodb - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).get_database("rocketwatch") - self.created_view = False - - async def create_minipool_proposal_view(self): - if self.created_view: - return - log.info("creating minipool proposal view") + self.monitor = Monitor("proposals-task", api_key=cfg.other.secrets.cronitor) + self.batch_size = 100 + self.cooldown = timedelta(minutes=5) + self.bot.loop.create_task(self.loop()) + + async def loop(self): + await self.bot.wait_until_ready() + await self.check_indexes() + while not self.bot.is_closed(): + p_id = time.time() + self.monitor.ping(state="run", series=p_id) + try: + log.debug("starting proposal task") + await self.fetch_proposals() + await self.create_latest_proposal_view() + log.debug("finished proposal task") + self.monitor.ping(state="complete", series=p_id) + except Exception as err: + await self.bot.report_error(err) + self.monitor.ping(state="fail", series=p_id) + finally: + await asyncio.sleep(self.cooldown.total_seconds()) + + async def check_indexes(self): + await self.bot.wait_until_ready() + try: + await self.bot.db.proposals.create_index("validator") + await self.bot.db.proposals.create_index("slot", unique=True) + await self.bot.db.proposals.create_index( + [("validator", ASCENDING), ("slot", DESCENDING)] + ) + except Exception as e: + log.warning(f"Could not create indexes: {e}") + + async def fetch_proposals(self): + if db_entry := (await self.bot.db.last_checked_block.find_one({"_id": cog_id})): + last_checked_slot = db_entry["slot"] + else: + last_checked_slot = 4700012 # last slot before merge + + latest_slot = int( + (await bacon.get_block_header("finalized"))["data"]["header"]["message"][ + "slot" + ] + ) + for slots in as_chunks( + range(last_checked_slot + 1, latest_slot + 1), self.batch_size + ): + log.info(f"Fetching proposals for slots {slots[0]} to {slots[-1]}") + await asyncio.gather(*[self.fetch_proposal(s) for s in slots]) + await self.bot.db.last_checked_block.replace_one( + {"_id": cog_id}, {"_id": cog_id, "slot": slots[-1]}, upsert=True + ) + + async def fetch_proposal(self, slot: int) -> None: + try: + beacon_header = (await bacon.get_block_header(str(slot)))["data"]["header"][ + "message" + ] + except ClientResponseError as e: + if e.status == 404: + return None + else: + raise e + + validator_index = int(beacon_header["proposer_index"]) + query = {"validator_index": validator_index} + is_megapool = await self.bot.db.minipools.count_documents(query, limit=1) + is_minipool = await self.bot.db.megapool_validators.count_documents( + query, limit=1 + ) + if not (is_minipool or is_megapool): + return None + + beacon_block = (await bacon.get_block(str(slot)))["data"]["message"] + proposal_data = parse_proposal(beacon_block) + await self.bot.db.proposals.update_one( + {"slot": slot}, {"$set": proposal_data}, upsert=True + ) + + async def create_latest_proposal_view(self): + log.info("creating latest proposals view") pipeline = [ { - '$match': { - 'node_operator': { - '$ne': None - }, - 'beacon.status' : 'active_ongoing', - "status": "staking" + "$match": { + "node_operator": {"$ne": None}, + "beacon.status": "active_ongoing", } - }, { - '$lookup': { - 'from' : 'proposals', - 'localField' : 'validator_index', - 'foreignField': 'validator', - 'as' : 'proposals', - 'pipeline' : [ + }, + { + "$unionWith": { + "coll": "minipools", + "pipeline": [ { - '$sort': { - 'slot': -1 + "$match": { + "node_operator": {"$ne": None}, + "beacon.status": "active_ongoing", } } - ] - } - }, { - '$project': { - 'node_operator': 1, - 'validator' : 1, - 'proposal' : { - '$arrayElemAt': [ - '$proposals', 0 - ] - } - } - }, { - '$project': { - 'node_operator': 1, - 'validator' : "$validator_index", - 'slot' : '$proposal.slot' - } - }, { - '$group': { - '_id' : '$node_operator', - 'slot' : { - '$max': '$slot' - }, - 'validator_count': { - '$sum': 1 - } + ], } - }, { - '$match': { - 'slot': { - '$ne': None - } + }, + { + "$lookup": { + "from": "proposals", + "localField": "validator_index", + "foreignField": "validator", + "as": "proposals", + "pipeline": [{"$sort": {"slot": -1}}, {"$limit": 1}], } - }, { - '$lookup': { - 'from' : 'proposals', - 'localField' : 'slot', - 'foreignField': 'slot', - 'as' : 'proposals' + }, + {"$unwind": {"path": "$proposals", "preserveNullAndEmptyArrays": True}}, + { + "$group": { + "_id": "$node_operator", + "validator_count": {"$sum": 1}, + "latest_proposal": {"$first": "$proposals"}, } - }, { - '$project': { - 'node_operator' : 1, - 'latest_proposal': { - '$arrayElemAt': [ - '$proposals', 0 - ] - }, - 'validator_count': 1 + }, + {"$match": {"latest_proposal": {"$ne": None}}}, + { + "$project": { + "_id": "$_id", + "node_operator": "$_id", + "validator_count": 1, + "latest_proposal": 1, } - } + }, ] - await self.db.minipool_proposals.drop() - await self.db.create_collection( - "minipool_proposals", - viewOn="minipools_new", - pipeline=pipeline + await self.bot.db.latest_proposals.drop() + await self.bot.db.create_collection( + "latest_proposals", viewOn="megapool_validators", pipeline=pipeline ) - self.created_view = True - - async def gather_all_proposals(self): - log.info("getting all proposals using the rocketscan.dev API") - async with aiohttp.ClientSession() as session: - async with session.get(self.rocketscan_proposals_url) as resp: - if resp.status != 200: - log.error("failed to get proposals using the rocketscan.dev API") - return - proposals = await resp.json() - log.info("got all proposals using the rocketscan.dev API") - await self.db.proposals.bulk_write([ReplaceOne({"slot": int(entry["number"])}, - PROPOSAL_TEMPLATE | parse_propsal(entry), - upsert=True) for entry in proposals]) - log.info("finished gathering all proposals") - - async def chore(self, ctx: Context): - # only run if self.last_chore_run timestamp is older than 1 hour - msg = await ctx.send(content="doing chores...") - if (time.time() - self.last_chore_run) > 3600: - self.last_chore_run = time.time() - await msg.edit(content="gathering proposals...") - await self.gather_all_proposals() - await self.create_minipool_proposal_view() - else: - log.debug("skipping chore") - return msg @timerun_async async def gather_attribute(self, attribute, remove_allnodes=False): - distribution = await self.db.minipool_proposals.aggregate([ + # Build the match stage to filter out Allnodes if needed + match_stage: dict = {} + if remove_allnodes: + match_stage["$match"] = {"latest_proposal.type": {"$ne": "Allnodes"}} + + pipeline: list[dict] = [ { - '$project': { - 'attribute' : f'$latest_proposal.{attribute}', - 'type' : '$latest_proposal.type', - 'validator_count': 1 - } - }, { - '$group': { - '_id' : ['$attribute', '$type'], - 'count' : { - '$sum': 1 - }, - 'validator_count': { - '$sum': '$validator_count' - } + "$project": { + "attribute": f"$latest_proposal.{attribute}", + "type": "$latest_proposal.type", + "validator_count": 1, } - }, { - '$sort': { - 'count': 1 + }, + { + "$group": { + "_id": {"attribute": "$attribute", "type": "$type"}, + "count": {"$sum": 1}, + "validator_count": {"$sum": "$validator_count"}, } - } - ]).to_list(length=None) + }, + ] + + # Add match stage at the beginning if filtering Allnodes + if remove_allnodes: + pipeline.insert(0, match_stage) + + distribution = await ( + await self.bot.db.latest_proposals.aggregate(pipeline) + ).to_list() + if remove_allnodes: - d = {'remove_from_total': {'count': 0, 'validator_count': 0}} + d = {"remove_from_total": {"count": 0, "validator_count": 0}} for entry in distribution: - if entry['_id'][1] == 'Allnodes': - d['remove_from_total']['count'] += entry['count'] - d['remove_from_total']['validator_count'] += entry['validator_count'] - else: - d[entry['_id'][0]] = entry + d[entry["_id"]["attribute"]] = entry return d else: - distribution = [entry | {'_id': entry['_id'][0]} for entry in distribution] - # merge entries that have the same _id by summing their attributes + # Convert nested _id structure and merge by attribute d = {} for entry in distribution: - if entry["_id"] in d: - d[entry["_id"]]["count"] += entry["count"] - d[entry["_id"]]["validator_count"] += entry["validator_count"] + key = entry["_id"]["attribute"] + if key in d: + d[key]["count"] += entry["count"] + d[key]["validator_count"] += entry["validator_count"] else: - d[entry["_id"]] = entry - return d + d[key] = entry + return d + + type Color = str | tuple[float, float, float, float] - @hybrid_command() - async def version_chart(self, ctx: Context): + @command() + @describe(days="how many days to show history for") + async def version_chart(self, interaction: Interaction, days: int = 90): """ Show a historical chart of used Smart Node versions """ - await ctx.defer(ephemeral=is_hidden(ctx)) - msg = await self.chore(ctx) - await msg.edit(content="generating version chart...") + await interaction.response.defer(ephemeral=is_hidden(interaction)) + + window_length = 5 e = Embed(title="Version Chart") - e.description = "The graph below shows proposal stats using a **5-day rolling window**, " \ - "and **does not represent operator adoption**.\n" \ - "Versions with a proposal in the **last 2 days** are emphasized.\n\n" \ - "The percentages in the top left legend show the percentage of proposals observed in the last 5 days using that version.\n" \ - "**If an old version is shown as 10%, it means that it was 10% of the proposals in the last 5 days.**\n" \ - "_No it does not mean that the minipools simply haven't proposed with the new version yet._\n" \ - "This only looks at proposals, it does not care about what individual minipools do." + e.description = ( + f"The graph below shows proposal stats using a **{window_length}-day rolling window**. " + f"It relies on proposal frequency to approximate adoption by active validator count." + ) # get proposals - # limit to 6 months - proposals = await self.db.proposals.find( - { - "version": {"$exists": 1}, - "slot" : {"$gt": date_to_beacon_block((datetime.now() - timedelta(days=180)).timestamp())} - }).sort("slot", 1).to_list(None) - look_back = int(60 / 12 * 60 * 24 * 2) # last 2 days - max_slot = proposals[-1]["slot"] - # get version used after max_slot - look_back - # and have at least 10 occurrences - start_slot = max_slot - look_back - recent_versions = await self.db.proposals.aggregate([ - { - '$match': { - 'slot' : { - '$gte': start_slot + # limit to specified number of days + proposals = ( + await self.bot.db.proposals.find( + { + "version": {"$exists": 1}, + "slot": { + "$gt": date_to_beacon_block( + int((datetime.now() - timedelta(days=days)).timestamp()) + ) }, - 'version': { - '$exists': 1 - } - } - }, { - '$group': { - '_id' : '$version' } - }, { - '$sort': { - '_id': -1 - } - } - ]).to_list(None) - recent_versions = [v['_id'] for v in recent_versions] + ) + .sort("slot", 1) + .to_list(None) + ) + max_slot = proposals[-1]["slot"] + # get versions used after max_slot - window + start_slot = max_slot - int(5 * 60 * 24 * window_length) + recent_versions = await ( + await self.bot.db.proposals.aggregate( + [ + { + "$match": { + "slot": {"$gte": start_slot}, + "version": {"$exists": 1}, + } + }, + {"$group": {"_id": "$version"}}, + {"$sort": {"_id": -1}}, + ] + ) + ).to_list() + recent_versions = [v["_id"] for v in recent_versions] data = {} versions = [] proposal_buffer = [] - tmp_data = {} - for i, proposal in enumerate(proposals): + tmp_data: dict[str, float] = {} + for proposal in proposals: proposal_buffer.append(proposal) if proposal["version"] not in versions: versions.append(proposal["version"]) tmp_data[proposal["version"]] = tmp_data.get(proposal["version"], 0) + 1 slot = proposal["slot"] - if i < 200: - continue - while proposal_buffer[0]["slot"] < slot - (60 / 12 * 60 * 24 * 5): + while proposal_buffer[0]["slot"] < slot - (5 * 60 * 24 * window_length): to_remove = proposal_buffer.pop(0) tmp_data[to_remove["version"]] -= 1 date = datetime.fromtimestamp(beacon_block_to_date(slot)) @@ -357,96 +376,139 @@ async def version_chart(self, ctx: Context): # use plt.stackplot to stack the data x = list(data.keys()) - y = {v: [] for v in versions} - for date, value_ in data.items(): + y: dict[str, list[float]] = {v: [] for v in versions} + for _date, value_ in data.items(): for version in versions: y[version].append(value_.get(version, 0)) - # matplotlib default color - matplotlib_colors = [color['color'] for color in list(mpl.rcParams['axes.prop_cycle'])] - # cap recent versions to available colors, but we want to prioritize the most recent versions - recent_versions = recent_versions[-len(matplotlib_colors):] - recent_colors = [matplotlib_colors[i] for i in range(len(recent_versions))] + # generate enough distinct colors for all recent versions + cmap = plt.colormaps["tab20"] + recent_colors = [ + cmap(i / max(len(recent_versions) - 1, 1)) + for i in range(len(recent_versions)) + ] # generate color mapping - colors = ["darkgray"] * len(versions) + colors: list[Proposals.Color] = ["darkgray"] * len(versions) for i, version in enumerate(versions): if version in recent_versions: colors[i] = recent_colors[recent_versions.index(version)] last_slot_data = data[max(x)] last_slot_data = {v: last_slot_data[v] for v in recent_versions} - labels = [f"{v} ({last_slot_data[v]:.2%})" if v in recent_versions else "_nolegend_" for v in versions] + labels = [ + f"{v} ({last_slot_data[v]:.2%})" if v in recent_versions else "_nolegend_" + for v in versions + ] # add percentage to labels - ax = plt.subplot(111, frameon=False) - plt.stackplot(x, *y.values(), labels=labels, colors=colors) + x_arr = np.array(x) + fig, ax = plt.subplots() + ax.stackplot(x_arr, *y.values(), labels=labels, colors=colors) # hide y axis - plt.tick_params(axis='y', which='both', left=False, right=False, labelleft=False) - ax.legend(loc="upper left") + ax.tick_params(axis="y", which="both", left=False, right=False, labelleft=False) + fig.autofmt_xdate() + handles, legend_labels = ax.get_legend_handles_labels() + ax.legend(reversed(handles), reversed(legend_labels), loc="upper left") # add a thin line at current time from y=0 to y=1 with a width of 0.5 - plt.plot([max(x), max(x)], [0, 1], color="white", alpha=0.25) + ax.plot([x_arr[-1], x_arr[-1]], [0, 1], color="white", alpha=0.25) # calculate future point to make latest data more visible - diff = x[-1] - x[0] - future_point = x[-1] + (diff * 0.05) + future_point = x[-1] + timedelta(days=window_length) last_y_values = [[yy[-1]] * 2 for yy in y.values()] - plt.stackplot([x[-1], future_point], *last_y_values, colors=colors) - plt.tight_layout() - - # the title should mention that the /version_chart command contains more information about how this chart works. but short - plt.title("READ DESC OF /version_chart IF CONFUSED", y=0.95, fontsize=9) + ax.stackplot( + [x_arr[-1], np.datetime64(future_point)], *last_y_values, colors=colors + ) + fig.tight_layout() # respond with image img = BytesIO() - plt.savefig(img, format="png", bbox_inches="tight", dpi=300) + fig.savefig(img, format="png", bbox_inches="tight", dpi=300) img.seek(0) - plt.close() + plt.close(fig) e.set_image(url="attachment://chart.png") # send data - await msg.edit(content="", embed=e, attachments=[File(img, filename="chart.png")]) + await interaction.followup.send(embed=e, file=File(img, filename="chart.png")) img.close() - async def plot_axes_with_data(self, attr: str, ax1, ax2, name, remove_allnodes=False): + async def plot_axes_with_data( + self, attr: str, ax1, ax2, remove_allnodes: bool = False + ): # group by client and get count data = await self.gather_attribute(attr, remove_allnodes) - minipools = [(x, y["validator_count"]) for x, y in data.items() if x != "remove_from_total"] + minipools = [ + (x, y["validator_count"]) + for x, y in data.items() + if x != "remove_from_total" + ] minipools = sorted(minipools, key=lambda x: x[1]) # get total minipool count from rocketpool - unobserved_minipools = len(await self.db.minipools_new.find({"beacon.status": "active_ongoing", "status": "staking"}).distinct("_id")) - sum(d[1] for d in minipools) + distinct_ids = await self.bot.db.minipools.find( + {"beacon.status": "active_ongoing", "status": "staking"} + ).distinct("_id") + unobserved_minipools = len(distinct_ids) - sum(d[1] for d in minipools) if "remove_from_total" in data: unobserved_minipools -= data["remove_from_total"]["validator_count"] minipools.insert(0, ("No proposals yet", unobserved_minipools)) # move "Unknown" to be before "No proposals yet" - minipools.insert(1, minipools.pop([i for i, (x, y) in enumerate(minipools) if x == "Unknown"][0])) + minipools.insert( + 1, + minipools.pop( + next(i for i, (x, y) in enumerate(minipools) if x == "Unknown") + ), + ) # move "External (if it exists) to be before "Unknown" # minipools is a list of tuples (name, count) if "External" in [x for x, y in minipools]: - minipools.insert(2, minipools.pop([i for i, (x, y) in enumerate(minipools) if x == "External"][0])) + minipools.insert( + 2, + minipools.pop( + next(i for i, (x, y) in enumerate(minipools) if x == "External") + ), + ) # get node operators - node_operators = [(x, y["count"]) for x, y in data.items() if x != "remove_from_total"] + node_operators = [ + (x, y["count"]) for x, y in data.items() if x != "remove_from_total" + ] node_operators = sorted(node_operators, key=lambda x: x[1]) # get total node operator count from rp - unobserved_node_operators = len(await self.db.minipools_new.find({"beacon.status": "active_ongoing", "status": "staking"}).distinct("node_operator")) - sum(d[1] for d in node_operators) + distinct_nos = await self.bot.db.minipools.find( + {"beacon.status": "active_ongoing", "status": "staking"} + ).distinct("node_operator") + unobserved_node_operators = len(distinct_nos) - sum( + d[1] for d in node_operators + ) if "remove_from_total" in data: unobserved_node_operators -= data["remove_from_total"]["count"] node_operators.insert(0, ("No proposals yet", unobserved_node_operators)) # move "Unknown" to be before "No proposals yet" - node_operators.insert(1, node_operators.pop([i for i, (x, y) in enumerate(node_operators) if x == "Unknown"][0])) + node_operators.insert( + 1, + node_operators.pop( + next(i for i, (x, y) in enumerate(node_operators) if x == "Unknown") + ), + ) # move "External (if it exists) to be before "Unknown" # node_operators is a list of tuples (name, count) if "External" in [x for x, y in node_operators]: - node_operators.insert(2, node_operators.pop([i for i, (x, y) in enumerate(node_operators) if x == "External"][0])) + node_operators.insert( + 2, + node_operators.pop( + next( + i for i, (x, y) in enumerate(node_operators) if x == "External" + ) + ), + ) # sort data ax1.pie( [x[1] for x in minipools], colors=[COLORS.get(x[0], "red") for x in minipools], - autopct=lambda pct: ('%.1f%%' % pct) if pct > 5 else '', + autopct=lambda pct: (f"{pct:.1f}%") if pct > 5 else "", startangle=90, - textprops={'fontsize': '12'}, + textprops={"fontsize": "12"}, ) # legend total_minipols = sum(x[1] for x in minipools) @@ -455,33 +517,37 @@ async def plot_axes_with_data(self, attr: str, ax1, ax2, name, remove_allnodes=F [f"{x[1]} {x[0]} ({x[1] / total_minipols:.2%})" for x in minipools], loc="lower left", bbox_to_anchor=(0, -0.1), - fontsize=11 + fontsize=11, ) ax1.set_title("Minipools", fontsize=22) ax2.pie( [x[1] for x in node_operators], colors=[COLORS.get(x[0], "red") for x in node_operators], - autopct=lambda pct: ('%.1f%%' % pct) if pct > 5 else '', + autopct=lambda pct: (f"{pct:.1f}%") if pct > 5 else "", startangle=90, - textprops={'fontsize': '12'}, + textprops={"fontsize": "12"}, ) # legend total_node_operators = sum(x[1] for x in node_operators) ax2.legend( - [f"{x[1]} {x[0]} ({x[1] / total_node_operators:.2%})" for x in node_operators], + [ + f"{x[1]} {x[0]} ({x[1] / total_node_operators:.2%})" + for x in node_operators + ], loc="lower left", bbox_to_anchor=(0, -0.1), - fontsize=11 + fontsize=11, ) ax2.set_title("Node Operators", fontsize=22) - async def proposal_vs_node_operators_embed(self, attribute, name, msg, remove_allnodes=False): + async def proposal_vs_node_operators_embed( + self, attribute, name, remove_allnodes: bool = False + ): fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 8)) # iterate axes in pairs title = f"Rocket Pool {name} Distribution {'without Allnodes' if remove_allnodes else ''}" - await msg.edit(content=f"generating {attribute} distribution graph...") - await self.plot_axes_with_data(attribute, ax1, ax2, name, remove_allnodes) + await self.plot_axes_with_data(attribute, ax1, ax2, remove_allnodes) e = Embed(title=title) @@ -491,9 +557,9 @@ async def proposal_vs_node_operators_embed(self, attribute, name, msg, remove_al # respond with image img = BytesIO() - plt.savefig(img, format="png") + fig.savefig(img, format="png") img.seek(0) - plt.close() + plt.close(fig) e.set_image(url=f"attachment://{attribute}.png") # send data @@ -501,118 +567,86 @@ async def proposal_vs_node_operators_embed(self, attribute, name, msg, remove_al img.close() return e, f - @hybrid_command() - async def client_distribution(self, ctx: Context, remove_allnodes=False): + @command() + async def client_distribution( + self, interaction: Interaction, remove_allnodes: bool = False + ): """ Generate a distribution graph of clients. """ - await ctx.defer(ephemeral=is_hidden(ctx)) - msg = await self.chore(ctx) + await interaction.response.defer(ephemeral=is_hidden(interaction)) embeds, files = [], [] - for attr, name in [["consensus_client", "Consensus Client"], ["execution_client", "Execution Client"]]: - e, f = await self.proposal_vs_node_operators_embed(attr, name, msg, remove_allnodes) + for attr, name in [ + ["consensus_client", "Consensus Client"], + ["execution_client", "Execution Client"], + ]: + e, f = await self.proposal_vs_node_operators_embed( + attr, name, remove_allnodes + ) embeds.append(e) files.append(f) - await msg.edit(content="", embeds=embeds, attachments=files) - - @hybrid_command() - async def user_distribution(self, ctx: Context): - """ - Generate a distribution graph of users. - """ - await ctx.defer(ephemeral=is_hidden(ctx)) - msg = await self.chore(ctx) - e, f = await self.proposal_vs_node_operators_embed("type", "User", msg) - await msg.edit(content="", embed=e, attachments=[f]) + await interaction.followup.send(embeds=embeds, files=files) - @hybrid_command() - async def comments(self, ctx: Context): + @command() + async def operator_type_distribution(self, interaction: Interaction): """ - Generate a world cloud of comments. + Generate a graph of NO groups. """ - await ctx.defer(ephemeral=is_hidden(ctx)) - msg = await self.chore(ctx) - await msg.edit(content="generating comments word cloud...") - - # load image - mask = np.array(Image.open("./plugins/proposals/assets/logo-words.png")) - - # load font - font_path = "./plugins/proposals/assets/noto.ttf" - - wc = WordCloud(max_words=2 ** 16, - scale=2, - mask=mask, - max_font_size=100, - min_font_size=1, - background_color="white", - relative_scaling=0, - font_path=font_path, - color_func=lambda *args, **kwargs: "rgb(235, 142, 85)") - - # aggregate comments with their count - comments = await self.db.proposals.aggregate([ - {"$match": {"comment": {"$exists": 1}}}, - {"$group": {"_id": "$comment", "count": {"$sum": 1}}}, - {"$sort": {"count": -1, "slot": -1}} - ]).to_list(None) - comment_words = {x['_id']: x["count"] for x in comments} - - # generate word cloud - wc.fit_words(comment_words) - - # respond with image - img = BytesIO() - wc.to_image().save(img, format="png") - img.seek(0) - plt.close() - e = Embed(title="Rocket Pool Proposal Comments") - e.set_image(url="attachment://image.png") - await msg.edit(content="", embed=e, attachments=[File(img, filename="image.png")]) - img.close() - - @hybrid_command() - async def client_combo_ranking(self, ctx: Context, remove_allnodes=False, group_by_node_operators=False): + await interaction.response.defer(ephemeral=is_hidden(interaction)) + embed, file = await self.proposal_vs_node_operators_embed("type", "User") + await interaction.followup.send(embed=embed, file=file) + + @command() + async def client_combo_ranking( + self, + interaction: Interaction, + remove_allnodes: bool = False, + group_by_node_operators: bool = False, + ): """ Generate a ranking of most used execution and consensus clients. """ - await ctx.defer(ephemeral=is_hidden(ctx)) - msg = await self.chore(ctx) - await msg.edit(content="generating client combo ranking...") - + await interaction.response.defer(ephemeral=is_hidden(interaction)) # aggregate [consensus, execution] pair counts - client_pairs = await self.db.minipool_proposals.aggregate([ - { - "$match": { - "latest_proposal.consensus_client": {"$ne": "Unknown"}, - "latest_proposal.execution_client": {"$ne": "Unknown"}, - "latest_proposal.type" : {"$ne": "Allnodes"} if remove_allnodes else {"$ne": "deadbeef"} - } - }, { - "$group": { - "_id" : { - "consensus": "$latest_proposal.consensus_client", - "execution": "$latest_proposal.execution_client" + client_pairs = await ( + await self.bot.db.latest_proposals.aggregate( + [ + { + "$match": { + "latest_proposal.consensus_client": {"$ne": "Unknown"}, + "latest_proposal.execution_client": {"$ne": "Unknown"}, + "latest_proposal.type": {"$ne": "Allnodes"} + if remove_allnodes + else {"$ne": "deadbeef"}, + } }, - "count": { - "$sum": 1 if group_by_node_operators else "$validator_count" - } - } - }, - { - "$sort": { - "count": -1 - } - } - ]).to_list(None) + { + "$group": { + "_id": { + "consensus": "$latest_proposal.consensus_client", + "execution": "$latest_proposal.execution_client", + }, + "count": { + "$sum": 1 + if group_by_node_operators + else "$validator_count" + }, + } + }, + {"$sort": {"count": -1}}, + ] + ) + ).to_list() - e = Embed(title=f"Client Combo Ranking{' without Allnodes' if remove_allnodes else ''}") + e = Embed( + title=f"Client Combo Ranking{' without Allnodes' if remove_allnodes else ''}" + ) # generate max width of both columns max_widths = [ - max(len(x['_id']['consensus']) for x in client_pairs), - max(len(x['_id']['execution']) for x in client_pairs), - max(len(str(x['count'])) for x in client_pairs) + max(len(x["_id"]["consensus"]) for x in client_pairs), + max(len(x["_id"]["execution"]) for x in client_pairs), + max(len(str(x["count"])) for x in client_pairs), ] desc = "".join( @@ -622,7 +656,7 @@ async def client_combo_ranking(self, ctx: Context, remove_allnodes=False, group_ for i, pair in enumerate(client_pairs) ) e.description = f"Currently showing {'node operator' if group_by_node_operators else 'validator'} counts\n```{desc}```" - await msg.edit(content="", embed=e) + await interaction.followup.send(embed=e) async def setup(bot): diff --git a/rocketwatch/plugins/queue/queue.py b/rocketwatch/plugins/queue/queue.py index 18bd3151..d7ffa8bb 100644 --- a/rocketwatch/plugins/queue/queue.py +++ b/rocketwatch/plugins/queue/queue.py @@ -1,146 +1,237 @@ -import math import logging +from typing import Literal, NamedTuple -from cachetools.func import ttl_cache +from aiocache import cached from discord import Interaction -from discord.app_commands import command +from discord.app_commands import command, describe from discord.ext.commands import Cog -from eth_typing import ChecksumAddress +from eth_typing import BlockIdentifier, ChecksumAddress from rocketwatch import RocketWatch -from utils import solidity -from utils.cfg import cfg -from utils.embeds import Embed from utils.embeds import el_explorer_url from utils.rocketpool import rp -from utils.visibility import is_hidden_weak from utils.shared_w3 import w3 from utils.views import PageView +from utils.visibility import is_hidden -log = logging.getLogger("queue") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.queue") class Queue(Cog): + class Entry(NamedTuple): + megapool: ChecksumAddress + validator_id: int + bond: int # always 4,000 for now + deposit_size: int # always 32,000 for now + def __init__(self, bot: RocketWatch): self.bot = bot - class MinipoolPageView(PageView): - def __init__(self): + class ValidatorPageView(PageView): + def __init__(self, lane: Literal["combined", "standard", "express"]): super().__init__(page_size=15) - + if lane == "standard": + self.queue_name = "🐢 Validator Standard Queue" + self.content_loader = Queue.get_standard_queue + elif lane == "express": + self.queue_name = "🐇 Validator Express Queue" + self.content_loader = Queue.get_express_queue + else: + self.queue_name = "Validator Queue" + self.content_loader = Queue.get_combined_queue + @property def _title(self) -> str: - return "Minipool Queue" - + return self.queue_name + async def _load_content(self, from_idx: int, to_idx: int) -> tuple[int, str]: - queue_length, queue_content = Queue.get_minipool_queue( + queue_length, queue_content = await self.content_loader( limit=(to_idx - from_idx + 1), start=from_idx ) return queue_length, queue_content @staticmethod - @ttl_cache(ttl=600) - def _cached_el_url(address, prefix="") -> str: - return el_explorer_url(address, name_fmt=lambda n: f"`{n}`", prefix=prefix) + @cached(key_builder=lambda _, address, prefix="": (address, prefix)) + async def _cached_el_url(address, prefix="") -> str: + return await el_explorer_url( + address, name_fmt=lambda n: f"`{n}`", prefix=prefix + ) + + @staticmethod + async def _megapool_to_node(megapool_address) -> ChecksumAddress: + return await rp.call( + "rocketMegapoolDelegate.getNodeAddress", address=megapool_address + ) @staticmethod - def get_minipool_queue(limit: int, start: int = 0) -> tuple[int, str]: - """Get the next {limit} minipools in the queue""" + async def __format_queue_entry(entry: "Queue.Entry") -> str: + node_address = await Queue._megapool_to_node(entry.megapool) + node_label = await Queue._cached_el_url(node_address) + return f"{node_label} #`{entry.validator_id}`" - queue_contract = rp.get_contract_by_name("addressQueueStorage") - key = w3.soliditySha3(["string"], ["minipools.available.variable"]) - q_len = queue_contract.functions.getLength(key).call() + @staticmethod + async def get_standard_queue(limit: int, start: int = 0) -> tuple[int, str]: + """Get the next {limit} validators in the standard queue""" + return await Queue._get_queue("deposit.queue.standard", limit, start) - start = max(start, 0) - limit = min(limit, q_len - start) + @staticmethod + async def get_express_queue(limit: int, start: int = 0) -> tuple[int, str]: + """Get the next {limit} validators in the express queue""" + return await Queue._get_queue("deposit.queue.express", limit, start) + + @staticmethod + async def _scan_list( + namespace: bytes, start: int, limit: int, block_identifier: BlockIdentifier + ) -> list["Queue.Entry"]: + list_contract = await rp.get_contract_by_name("linkedListStorage") + raw_entries, _ = await list_contract.functions.scan( + namespace, 0, start + limit + ).call(block_identifier=block_identifier) + return [Queue.Entry(*entry) for entry in raw_entries][start:] + @staticmethod + async def _get_queue(namespace: str, limit: int, start: int = 0) -> tuple[int, str]: if limit <= 0: return 0, "" - queue: list[ChecksumAddress] = [ - w3.to_checksum_address(res.results[0]) for res in rp.multicall.aggregate([ - queue_contract.functions.getItem(key, i) for i in range(start, start + limit) - ]).results - ] - mp_contracts = [rp.assemble_contract("rocketMinipool", address=minipool) for minipool in queue] - nodes: list[ChecksumAddress] = [ - w3.to_checksum_address(res.results[0]) for res in rp.multicall.aggregate([ - contract.functions.getNodeAddress() for contract in mp_contracts - ]).results - ] - status_times: list[int] = [ - res.results[0] for res in rp.multicall.aggregate([ - contract.functions.getStatusTime() for contract in mp_contracts - ]).results - ] + list_contract = await rp.get_contract_by_name("linkedListStorage") + queue_namespace = bytes(w3.solidity_keccak(["string"], [namespace])) + + start = max(start, 0) + latest_block = await w3.eth.get_block_number() + q_len = await list_contract.functions.getLength(queue_namespace).call( + block_identifier=latest_block + ) + + if start >= q_len: + return q_len, "" + + queue_entries = await Queue._scan_list( + queue_namespace, start, limit, latest_block + ) content = "" - for i, minipool in enumerate(queue[:limit]): - mp_label = Queue._cached_el_url(minipool, -1) - node_label = Queue._cached_el_url(nodes[i]) - content += f"{start+i+1}. {mp_label} :construction_site: by {node_label}\n" + for i, entry in enumerate(queue_entries): + entry_str = await Queue.__format_queue_entry(entry) + content += f"{start + i + 1}. {entry_str}\n" return q_len, content - @command() - async def queue(self, interaction: Interaction): - """Show the minipool queue""" - await interaction.response.defer(ephemeral=is_hidden_weak(interaction)) - view = Queue.MinipoolPageView() - embed = await view.load() - await interaction.followup.send(embed=embed, view=view) + @staticmethod + def _get_entries_used_in_interval( + start: int, end: int, len_express: int, len_standard: int, express_rate: int + ) -> tuple[int, int]: + log.debug( + f"Calculating entries used in interval [{start}, {end}] with express_rate {express_rate}" + f" and queue lengths {len_express} (express) and {len_standard} (standard)" + ) - @command() - async def clear_queue(self, interaction: Interaction): - """Show gas price for clearing the queue using the rocketDepositPoolQueue contract""" - await interaction.response.defer(ephemeral=is_hidden_weak(interaction)) - - e = Embed(title="Gas Prices for Dequeuing Minipools") - e.set_author( - name="🔗 Forum: Clear minipool queue contract", - url="https://dao.rocketpool.net/t/clear-minipool-queue-contract/670" + total_entries = end - start + 1 # end is inclusive + num_standard = total_entries // (express_rate + 1) + # standard queue is used when index % (express_queue_rate + 1) == express_queue_rate + # this checks whether we "cross" an extra express queue slot in the interval + if ((end + 1) % (express_rate + 1)) < (start % (express_rate + 1)): + num_standard += 1 + + num_standard = min(num_standard, len_standard) + # if standard queue runs out, remaining entries are taken from express queue + num_express = min(total_entries - num_standard, len_express) + # if express queue runs out, remaining entries are taken from standard queue + if (num_express + num_standard) < total_entries: + num_standard = min(total_entries - num_express, len_standard) + + return num_express, num_standard + + @staticmethod + async def get_combined_queue(limit: int, start: int = 0) -> tuple[int, str]: + """Get the next {limit} validators in the combined queue (express + standard)""" + + latest_block = await w3.eth.get_block_number() + express_queue_rate = await rp.call( + "rocketDAOProtocolSettingsDeposit.getExpressQueueRate", block=latest_block + ) + queue_index = await rp.call( + "rocketDepositPool.getQueueIndex", block=latest_block ) - queue_length = rp.call("rocketMinipoolQueue.getTotalLength") - dp_balance = solidity.to_float(rp.call("rocketDepositPool.getBalance")) - match_amount = solidity.to_float(rp.call("rocketDAOProtocolSettingsMinipool.getVariableDepositAmount")) - max_dequeues = min(int(dp_balance / match_amount), queue_length) - - if max_dequeues > 0: - max_assignments = rp.call("rocketDAOProtocolSettingsDeposit.getMaximumDepositAssignments") - min_assignments = rp.call("rocketDAOProtocolSettingsDeposit.getMaximumDepositSocialisedAssignments") - - # half queue clear - half_clear_count = int(max_dequeues / 2) - half_clear_input = max_assignments * math.ceil(half_clear_count / min_assignments) - gas = rp.estimate_gas_for_call("rocketDepositPoolQueue.clearQueueUpTo", half_clear_input) - e.add_field( - name=f"Half Clear ({half_clear_count} MPs)", - value=f"`clearQueueUpTo({half_clear_input})`\n `{gas:,}` gas" - ) + list_contract = await rp.get_contract_by_name("linkedListStorage") + exp_namespace = bytes(w3.solidity_keccak(["string"], ["deposit.queue.express"])) + std_namespace = bytes( + w3.solidity_keccak(["string"], ["deposit.queue.standard"]) + ) - # full queue clear - full_clear_size = max_dequeues - full_clear_input = max_assignments * math.ceil(full_clear_size / min_assignments) - gas = rp.estimate_gas_for_call("rocketDepositPoolQueue.clearQueueUpTo", full_clear_input) - e.add_field( - name=f"Full Clear ({full_clear_size} MPs)", - value=f"`clearQueueUpTo({full_clear_input})`\n `{gas:,}` gas" - ) - elif queue_length > 0: - e.description = "Not enough funds in deposit pool to dequeue any minipools." - else: - e.description = "Queue is empty." - - # link to contract - e.add_field( - name="Contract", - value=el_explorer_url(rp.get_address_by_name('rocketDepositPoolQueue'), "RocketDepositPoolQueue"), - inline=False + express_queue_length = await list_contract.functions.getLength( + exp_namespace + ).call(block_identifier=latest_block) + standard_queue_length = await list_contract.functions.getLength( + std_namespace + ).call(block_identifier=latest_block) + q_len = express_queue_length + standard_queue_length + + if start >= q_len: + return q_len, "" + + start_express_queue, start_standard_queue = Queue._get_entries_used_in_interval( + queue_index, + queue_index + start - 1, + express_queue_length, + standard_queue_length, + express_queue_rate, ) + log.debug(f"{start_express_queue = }") + log.debug(f"{start_standard_queue = }") + limit_express_queue, limit_standard_queue = Queue._get_entries_used_in_interval( + queue_index + start, + queue_index + start + limit - 1, + express_queue_length - start_express_queue, + standard_queue_length - start_standard_queue, + express_queue_rate, + ) + log.debug(f"{limit_express_queue = }") + log.debug(f"{limit_standard_queue = }") + + express_entries_rev = ( + await Queue._scan_list( + exp_namespace, start_express_queue, limit_express_queue, latest_block + ) + )[::-1] + standard_entries_rev = ( + await Queue._scan_list( + std_namespace, start_standard_queue, limit_standard_queue, latest_block + ) + )[::-1] + + content = "" + for i in range(len(express_entries_rev) + len(standard_entries_rev)): + effective_queue_index = queue_index + start + i + is_express = ( + effective_queue_index % (express_queue_rate + 1) + ) != express_queue_rate + if (is_express and express_entries_rev) or (not standard_entries_rev): + entry = express_entries_rev.pop() + lane_pos = "🐇" + else: + entry = standard_entries_rev.pop() + lane_pos = "🐢" + + overall_pos = start + i + 1 + entry_str = await Queue.__format_queue_entry(entry) + content += f"{overall_pos}. {lane_pos} {entry_str}\n" - await interaction.followup.send(embed=e) + return q_len, content + + @command() + @describe(lane="type of queue to display") + async def queue( + self, + interaction: Interaction, + lane: Literal["combined", "standard", "express"] = "combined", + ): + """Show the RP validator queue""" + await interaction.response.defer(ephemeral=is_hidden(interaction)) + view = Queue.ValidatorPageView(lane) + embed = await view.load() + await interaction.followup.send(embed=embed, view=view) async def setup(bot): diff --git a/rocketwatch/plugins/random/random.py b/rocketwatch/plugins/random/random.py index a56f55ac..8aa0cc3b 100644 --- a/rocketwatch/plugins/random/random.py +++ b/rocketwatch/plugins/random/random.py @@ -1,75 +1,74 @@ -import io import logging +import random from datetime import datetime import aiohttp import dice import humanize import pytz -from discord import File +from discord import Interaction +from discord.app_commands import Choice, command from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command -from motor.motor_asyncio import AsyncIOMotorClient +from eth_typing import HexStr +from web3.contract import AsyncContract +from web3.types import TxData from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg -from utils.embeds import Embed, ens, el_explorer_url -from utils.readable import s_hex, uptime +from utils.block_time import block_to_ts, ts_to_block +from utils.config import cfg +from utils.embeds import Embed, el_explorer_url, ens +from utils.file import TextFile +from utils.readable import prettify_json_string, pretty_time, s_hex from utils.rocketpool import rp -from utils.sea_creatures import sea_creatures, get_sea_creature_for_address, get_holding_for_address -from utils.shared_w3 import w3, bacon -from utils.visibility import is_hidden, is_hidden_weak +from utils.sea_creatures import ( + get_holding_for_address, + get_sea_creature_for_address, + sea_creatures, +) +from utils.shared_w3 import bacon, w3 +from utils.visibility import is_hidden, is_hidden_role_controlled -log = logging.getLogger("random") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.random") class Random(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).get_database("rocketwatch") + self.contract_names: list[str] = [] - @hybrid_command() - async def dice(self, ctx: Context, dice_string: str = "1d6"): - await ctx.defer(ephemeral=is_hidden_weak(ctx)) - try: - result = dice.roll(dice_string) - except dice.exceptions.DiceException as e: - await ctx.send(f"Dice Error:\n```{e}```") - return - except dice.exceptions.DiceFatalException as e: - await ctx.send(f"Dice Fatal Error:\n```{e}```") - return - except dice.exceptions.ParseException as e: - await ctx.send(f"Dice Parse Error:\n```{e}```") - return - except dice.exceptions.ParseFatalException as e: - await ctx.send(f"Dice Parse Fatal Error:\n```{e}```") - return + @commands.Cog.listener() + async def on_ready(self): + if not self.contract_names: + self.contract_names = list(rp.addresses) + + @command() + async def dice(self, interaction: Interaction, dice_string: str = "1d6"): + await interaction.response.defer(ephemeral=is_hidden(interaction)) + result = dice.roll(dice_string) e = Embed() e.title = f"🎲 {dice_string}" if len(str(result)) >= 2000: e.description = "Result too long to display, attaching as file." - file = File(io.StringIO(str(result)), filename="dice_result.txt") - await ctx.send(embed=e, file=file) + file = TextFile(str(result), "dice_result.txt") + await interaction.followup.send(embed=e, file=file) else: e.description = f"Result: `{result}`" - await ctx.send(embed=e) + await interaction.followup.send(embed=e) - @hybrid_command() - async def burn_reason(self, ctx: Context): + @command() + async def burn_reason(self, interaction: Interaction): """Show the largest sources of burned ETH""" - await ctx.defer(ephemeral=is_hidden_weak(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) url = "https://ultrasound.money/api/fees/grouped-analysis-1" # get data from url using aiohttp - async with aiohttp.ClientSession() as session: - async with session.get(url) as resp: - data = await resp.json() + async with aiohttp.ClientSession() as session, session.get(url) as resp: + data = await resp.json() e = Embed() - e.set_author(name="🔗 Data from ultrasound.money", url="https://ultrasound.money") + e.set_author( + name="🔗 Data from ultrasound.money", url="https://ultrasound.money" + ) description = "**ETH Burned:**\n```" feesburned = data["feesBurned"] for span in ["5m", "1h", "24h"]: @@ -78,7 +77,7 @@ async def burn_reason(self, ctx: Context): description += "```\n" description += "**Burn Ranking (last 5 minutes)**\n" ranking = data["leaderboards"]["leaderboard5m"][:5] - + for i, entry in enumerate(ranking): # use a number emoji as rank (:one:, :two:, ...) # first of convert the number to a word @@ -86,26 +85,26 @@ async def burn_reason(self, ctx: Context): if "address" not in entry: description += f" {entry['name']}" else: - url = cfg["execution_layer.explorer"] + url = cfg.execution_layer.explorer if not entry["name"]: entry["name"] = s_hex(entry["address"]) target = f"[{entry['name']}]({url}/address/{entry['address']})" description += f" {target}" if entry.get("category"): description += f" `[{entry['category'].upper()}]`" - + description += "\n\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0" description += f"`{solidity.to_float(entry['fees']):,.2f} ETH` :fire:\n" - + e.add_field( name="Current Base Fee", - value=f"`{solidity.to_float(data['latestBlockFees'][0]['baseFeePerGas'], 9):,.2f} GWEI`" + value=f"`{solidity.to_float(data['latestBlockFees'][0]['baseFeePerGas'], 9):,.2f} GWEI`", ) e.description = description - await ctx.send(embed=e) + await interaction.followup.send(embed=e) - @hybrid_command() - async def dev_time(self, ctx: Context): + @command() + async def dev_time(self, interaction: Interaction): """Timezones too confusing to you? Well worry no more, this command is here to help!""" e = Embed() time_format = "%A %H:%M:%S %Z" @@ -118,198 +117,501 @@ async def dev_time(self, ctx: Context): uint_day = int(percentage_of_day * 65535) # generate binary string binary_day = f"{uint_day:016b}" - e.add_field(name="Coordinated Universal Time", - value=f"{dev_time.strftime(time_format)}\n" - f"`{binary_day} (0x{uint_day:04x})`") - b = solidity.slot_to_beacon_day_epoch_slot(int(bacon.get_block("head")["data"]["message"]["slot"])) + e.add_field( + name="Coordinated Universal Time", + value=f"{dev_time.strftime(time_format)}\n" + f"`{binary_day} (0x{uint_day:04x})`", + ) + head_slot = int( + (await bacon.get_block_header("head"))["data"]["header"]["message"]["slot"] + ) + b = solidity.slot_to_beacon_day_epoch_slot(head_slot) e.add_field(name="Beacon Time", value=f"Day {b[0]}, {b[1]}:{b[2]}") dev_time = datetime.now(tz=pytz.timezone("Australia/Lindeman")) - e.add_field(name="Time for most of the Dev Team", value=dev_time.strftime(time_format), inline=False) - - joe_time = datetime.now(tz=pytz.timezone("America/New_York")) - e.add_field(name="Joe's Time", value=joe_time.strftime(time_format), inline=False) + e.add_field( + name="Most of the core team", + value=dev_time.strftime(time_format), + inline=False, + ) fornax_time = datetime.now(tz=pytz.timezone("America/Sao_Paulo")) - e.add_field(name="Fornax's Time", value=fornax_time.strftime(time_format), inline=False) + e.add_field( + name="Fornax", value=fornax_time.strftime(time_format), inline=False + ) + e.add_field(name="Mav", value="Who even knows", inline=False) - await ctx.send(embed=e) + await interaction.response.send_message(embed=e) - @hybrid_command() - async def sea_creatures(self, ctx: Context, address: str = None): + @command() + async def sea_creatures( + self, interaction: Interaction, address: str | None = None + ) -> None: """List all sea creatures with their required minimum holding.""" - await ctx.defer(ephemeral=is_hidden(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) e = Embed() if address is not None: + address = address.strip() try: - if ".eth" in address: - address = ens.resolve_name(address) - address = w3.toChecksumAddress(address) + if address.endswith(".eth"): + address = await ens.resolve_name(address) + if address is None: + raise ValueError("unresolved ENS") + address = w3.to_checksum_address(address) except (ValueError, TypeError): e.description = "Invalid address" - await ctx.send(embed=e) + await interaction.followup.send(embed=e) return - creature = get_sea_creature_for_address(address) + creature = await get_sea_creature_for_address(address) if not creature: e.description = f"No sea creature for {address}" else: # get the required holding from the dictionary - required_holding = [h for h, c in sea_creatures.items() if c == creature[0]][0] - e.add_field(name="Visualization", value=el_explorer_url(address, prefix=creature), inline=False) - e.add_field(name="Required holding for emoji", value=f"{required_holding * len(creature)} ETH", inline=False) - holding = get_holding_for_address(address) - e.add_field(name="Actual Holding", value=f"{holding:.0f} ETH", inline=False) + required_holding = next( + h for h, c in sea_creatures.items() if c == creature[0] + ) + e.add_field( + name="Visualization", + value=await el_explorer_url(address, prefix=creature), + inline=False, + ) + e.add_field( + name="Required holding for emoji", + value=f"{required_holding * len(creature)} ETH", + inline=False, + ) + holding = await get_holding_for_address(address) + e.add_field( + name="Actual Holding", value=f"{holding:.0f} ETH", inline=False + ) else: e.title = "Possible Sea Creatures" e.description = "RPL (both old and new), rETH and ETH are consider as assets for the sea creature determination!" for holding_value, sea_creature in sea_creatures.items(): - e.add_field(name=f"{sea_creature}:", value=f"holds over {holding_value} ETH worth of assets", - inline=False) - await ctx.send(embed=e) - return + e.add_field( + name=f"{sea_creature}:", + value=f"holds over {holding_value} ETH worth of assets", + inline=False, + ) + await interaction.followup.send(embed=e) - @hybrid_command() - async def smoothie(self, ctx: Context): + @command() + async def smoothie(self, interaction: Interaction) -> None: """Show smoothing pool information""" - try: - rp.get_address_by_name("rocketSmoothingPool") - except Exception as err: - log.exception(err) - await ctx.send("redstone not deployed yet", ephemeral=True) - return - await ctx.defer(ephemeral=is_hidden_weak(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) e = Embed(title="Smoothing Pool") - smoothie_eth = solidity.to_float(w3.eth.get_balance(rp.get_address_by_name("rocketSmoothingPool"))) - data = await self.db.minipools_new.aggregate([ - { - '$match': { - 'beacon.status': { - '$nin': [ - 'exited_unslashed', 'exited_slashed', 'withdrawal_possible', 'withdrawal_done', - 'pending_initialized' - ] - } - } - }, { - '$group': { - '_id' : '$node_operator', - 'count': { - '$sum': 1 - } - } - }, { - '$lookup': { - 'from' : 'node_operators_new', - 'localField' : '_id', - 'foreignField': 'address', - 'as' : 'meta' - } - }, { - '$unwind': { - 'path' : '$meta', - 'preserveNullAndEmptyArrays': True - } - }, { - '$project': { - '_id' : 1, - 'count' : 1, - 'smoothie': '$meta.smoothing_pool_registration_state' - } - }, { - '$group': { - '_id' : '$smoothie', - 'count' : { - '$sum': '$count' + smoothie_eth = solidity.to_float( + await w3.eth.get_balance( + await rp.get_address_by_name("rocketSmoothingPool") + ) + ) + active_statuses = ["active_ongoing", "active_exiting"] + data = await ( + await self.bot.db.minipools.aggregate( + [ + {"$match": {"beacon.status": {"$in": active_statuses}}}, + {"$project": {"node_operator": 1}}, + { + "$unionWith": { + "coll": "megapool_validators", + "pipeline": [ + {"$match": {"beacon.status": {"$in": active_statuses}}}, + {"$project": {"node_operator": 1}}, + ], + } + }, + {"$group": {"_id": "$node_operator", "count": {"$sum": 1}}}, + { + "$lookup": { + "from": "node_operators", + "localField": "_id", + "foreignField": "address", + "as": "meta", + } + }, + {"$unwind": {"path": "$meta", "preserveNullAndEmptyArrays": True}}, + { + "$project": { + "_id": 1, + "count": 1, + "smoothie": "$meta.smoothing_pool_registration", + } }, - 'node_count': { - '$sum': 1 + { + "$group": { + "_id": "$smoothie", + "count": {"$sum": "$count"}, + "node_count": {"$sum": 1}, + "counts": { + "$addToSet": {"count": "$count", "address": "$_id"} + }, + } }, - 'counts' : { - '$addToSet': { - 'count' : '$count', - 'address': '$_id' + { + "$project": { + "_id": 1, + "count": 1, + "node_count": 1, + "counts": { + "$sortArray": { + "input": "$counts", + "sortBy": {"count": -1}, + } + }, } - } - } - }, { - '$project': { - '_id' : 1, - 'count' : 1, - 'node_count': 1, - 'counts' : { - '$sortArray': { - 'input' : '$counts', - 'sortBy': { - 'count': -1 - } + }, + { + "$project": { + "_id": 1, + "count": 1, + "node_count": 1, + "counts": {"$slice": ["$counts", 5]}, } - } - } - }, { - '$project': { - '_id' : 1, - 'count' : 1, - 'node_count': 1, - 'counts' : { - '$slice': [ - '$counts', 5 - ] - } - } - } - ]).to_list(length=None) + }, + ] + ) + ).to_list() if not data: - await ctx.send("no minipools found", ephemeral=True) + await interaction.followup.send("No validators found.", ephemeral=True) return - data = {d["_id"]: d for d in data} + + data_by_id = {d["_id"]: d for d in data} # node counts - total_node_count = data[True]["node_count"] + data[False]["node_count"] - smoothie_node_count = data[True]["node_count"] - # minipool counts - total_minipool_count = data[True]["count"] + data[False]["count"] - smoothie_minipool_count = data[True]["count"] - d = datetime.now().timestamp() - rp.call("rocketRewardsPool.getClaimIntervalTimeStart") - e.description = f"`{smoothie_node_count}/{total_node_count}` nodes (`{smoothie_node_count / total_node_count:.2%}`)" \ - f" have joined the smoothing pool.\n" \ - f" That is `{smoothie_minipool_count}/{total_minipool_count}` minipools " \ - f"(`{smoothie_minipool_count / total_minipool_count:.2%}`).\n" \ - f"The current (not overall) balance is **`{smoothie_eth:,.2f}` ETH.**\n" \ - f"This is over a span of `{uptime(d)}`.\n\n" \ - f"{min(smoothie_node_count, 5)} largest nodes:\n" - e.description += "\n".join(f"- `{d['count']:>4}` minipools - {el_explorer_url(d['address'])}" for d in - data[True]["counts"][:min(smoothie_node_count, 5)]) - await ctx.send(embed=e) - - @hybrid_command() - async def odao_challenges(self, ctx: Context): + total_node_count = ( + data_by_id[True]["node_count"] + data_by_id[False]["node_count"] + ) + smoothie_node_count = data_by_id[True]["node_count"] + # validator counts + total_validator_count = data_by_id[True]["count"] + data_by_id[False]["count"] + smoothie_validator_count = data_by_id[True]["count"] + d = datetime.now().timestamp() - await rp.call( + "rocketRewardsPool.getClaimIntervalTimeStart" + ) + e.description = ( + f"`{smoothie_node_count}/{total_node_count}` nodes (`{smoothie_node_count / total_node_count:.2%}`)" + f" have joined the smoothing pool.\n" + f" That is `{smoothie_validator_count}/{total_validator_count}` validators" + f" (`{smoothie_validator_count / total_validator_count:.2%}`).\n" + f"The current balance is **`{smoothie_eth:,.2f}` ETH**, {pretty_time(d)} into the reward period.\n\n" + f"{min(smoothie_node_count, 5)} largest nodes:\n" + ) + lines = [ + f"- `{d['count']:>4}` validators - {await el_explorer_url(d['address'])}" + for d in data_by_id[True]["counts"][: min(smoothie_node_count, 5)] + ] + e.description += "\n".join(lines) + await interaction.followup.send(embed=e) + + @command() + async def odao_challenges(self, interaction: Interaction) -> None: """Shows the current oDAO challenges""" - await ctx.defer(ephemeral=is_hidden_weak(ctx)) - c = rp.get_contract_by_name("rocketDAONodeTrustedActions") + await interaction.response.defer(ephemeral=is_hidden(interaction)) + c = await rp.get_contract_by_name("rocketDAONodeTrustedActions") # get challenges made - events = c.events["ActionChallengeMade"].createFilter( - fromBlock=w3.eth.get_block("latest").number - 7 * 24 * 60 * 60 // 12) - # get all events - events = events.get_all_entries() + events = list( + c.events["ActionChallengeMade"].get_logs( + from_block=(await w3.eth.get_block("latest")).get("number", 0) + - 7 * 24 * 60 * 60 // 12 + ) + ) # remove all events of nodes that aren't challenged anymore for event in events: - if not rp.call("rocketDAONodeTrusted.getMemberIsChallenged", event.args.nodeChallengedAddress): + if not await rp.call( + "rocketDAONodeTrusted.getMemberIsChallenged", + event.args.nodeChallengedAddress, + ): events.remove(event) # sort by block number events.sort(key=lambda x: x.blockNumber) if not events: - await ctx.send("no active challenges found") + await interaction.followup.send("No active challenges found") return e = Embed(title="Active oDAO Challenges") e.description = "" # get duration of challenge period - challenge_period = rp.call("rocketDAONodeTrustedSettingsMembers.getChallengeWindow") + challenge_period = await rp.call( + "rocketDAONodeTrustedSettingsMembers.getChallengeWindow" + ) for event in events: - time_left = challenge_period - (w3.eth.get_block("latest").timestamp - event.args.time) - time_left = uptime(time_left, True) - e.description += f"**{el_explorer_url(event.args.nodeChallengedAddress)}** was challenged by **{el_explorer_url(event.args.nodeChallengerAddress)}**\n" + latest_block = await w3.eth.get_block("latest") + time_left = challenge_period - ( + latest_block.get("timestamp", 0) - event.args.time + ) + time_left = pretty_time(time_left) + challenged = await el_explorer_url(event.args.nodeChallengedAddress) + challenger = await el_explorer_url(event.args.nodeChallengerAddress) + e.description += f"**{challenged}** was challenged by **{challenger}**\n" e.description += f"Time Left: **{time_left}**\n\n" - await ctx.send(embed=e) + await interaction.followup.send(embed=e) + + @command() + async def asian_restaurant_name(self, interaction: Interaction) -> None: + """ + Randomly generated Asian restaurant name + """ + await interaction.response.defer(ephemeral=is_hidden(interaction)) + async with ( + aiohttp.ClientSession() as session, + session.get( + "https://www.dotomator.com/api/random_name.json?type=asian" + ) as resp, + ): + a = (await resp.json())["name"] + await interaction.followup.send(a) + + @command() + async def mexican_restaurant_name(self, interaction: Interaction) -> None: + """ + Randomly generated Mexican restaurant name + """ + prefix = random.choice( + [ + "El", + "La", + "Los", + "Las", + "Casa", + "Don", + "Doña", + "Taco", + "Señor", + "Mi", + "Tres", + "Dos", + "El Gran", + "La Casa de", + "Rancho", + "Hacienda", + "Cocina", + "Pueblo", + "Villa", + "Cantina", + ] + ) + middle = random.choice( + [ + "Fuego", + "Sol", + "Luna", + "Loco", + "Grande", + "Diablo", + "Oro", + "Rojo", + "Verde", + "Azteca", + "Maya", + "Jalisco", + "Oaxaca", + "Baja", + "Bravo", + "Charro", + "Gordo", + "Amigo", + "Hermano", + "Fiesta", + "Coyote", + "Tigre", + "Águila", + "Toro", + "Mariposa", + "Cielo", + "Sombrero", + "Guapo", + "Rico", + "Caliente", + "Bonito", + "Fresco", + ] + ) + suffix = random.choice( + [ + "Cantina", + "Grill", + "Kitchen", + "Cocina", + "Taqueria", + "Restaurante", + "Mexican Grill", + "Tex-Mex", + "Cocina & Bar", + "Street Tacos", + "Cantina & Grill", + "Mexican Kitchen", + "Burrito Bar", + "", + ] + ) + await interaction.response.send_message(f"{prefix} {middle} {suffix}") + + @command() + async def austrian_restaurant_name(self, interaction: Interaction) -> None: + """ + Randomly generated Austrian restaurant name + """ + venues = [ + "Gasthaus", + "Gasthof", + "Wirtshaus", + "Beisl", + "Stüberl", + "Heuriger", + "Landgasthof", + "Alpengasthof", + "Berggasthof", + "Café-Restaurant", + "Braugasthof", + "Jausenstation", + ] + # (noun, gender): m = masculine, f = feminine, n = neuter + nouns = [ + ("Adler", "m"), + ("Hirsch", "m"), + ("Bär", "m"), + ("Ochse", "m"), + ("Löwe", "m"), + ("Hahn", "m"), + ("Schwan", "m"), + ("Fuchs", "m"), + ("Wolf", "m"), + ("Steinbock", "m"), + ("Falke", "m"), + ("Auerhahn", "m"), + ("Gamsbock", "m"), + ("Dachs", "m"), + ("Lamm", "n"), + ("Rößl", "n"), + ("Murmeltier", "n"), + ("Kreuz", "n"), + ("Krone", "f"), + ("Forelle", "f"), + ("Linde", "f"), + ("Rose", "f"), + ("Gams", "f"), + ] + adj_stems = [ + "golden", + "schwarz", + "weiß", + "grün", + "wild", + "alt", + "klein", + "groß", + "lustig", + "brav", + "fein", + "rot", + ] + nom_endings = {"m": "er", "f": "e", "n": "es"} + + noun, gender = random.choice(nouns) + stem = random.choice(adj_stems) + + # 30% chance for "Zum/Zur" style (dative), otherwise "Venue" style (nominative) + if random.random() < 0.3: + article = "Zur" if gender == "f" else "Zum" + adj = stem.capitalize() + "en" + name = f"{article} {adj} {noun}" + else: + venue = random.choice(venues) + adj = stem.capitalize() + nom_endings[gender] + name = f"{venue} {adj} {noun}" + + await interaction.response.send_message(name) + + @command() + async def get_block_by_timestamp(self, interaction: Interaction, timestamp: int): + """ + Get a block using its timestamp. Useful for contracts that track block time instead of block number. + """ + await interaction.response.defer(ephemeral=is_hidden(interaction)) + + block = await ts_to_block(timestamp) + found_ts = await block_to_ts(block) + + if found_ts == timestamp: + text = f"Found perfect match for timestamp {timestamp}:\nBlock: {block}" + else: + text = ( + f"Found close match for timestamp {timestamp}:\n" + f"Timestamp: {found_ts}\n" + f"Block: {block}" + ) + + await interaction.followup.send(content=f"```{text}```") + + @command() + async def get_abi_of_contract(self, interaction: Interaction, contract: str): + """Retrieve the latest ABI for a contract""" + await interaction.response.defer( + ephemeral=is_hidden_role_controlled(interaction) + ) + try: + abi = prettify_json_string(await rp.uncached_get_abi_by_name(contract)) + file = TextFile(abi, f"{contract}.{cfg.rocketpool.chain.lower()}.abi.json") + await interaction.followup.send(file=file) + except Exception as err: + await interaction.followup.send(content=f"```Exception: {err!r}```") + + @command() + async def get_address_of_contract(self, interaction: Interaction, contract: str): + """Retrieve the latest address for a contract""" + await interaction.response.defer( + ephemeral=is_hidden_role_controlled(interaction) + ) + try: + address = cfg.rocketpool.manual_addresses.get(contract) + if not address: + address = await rp.uncached_get_address_by_name(contract) + await interaction.followup.send(content=await el_explorer_url(address)) + except Exception as err: + await interaction.followup.send(content=f"Exception: ```{err!r}```") + if "No address found for" in repr(err): + # private response as a tip + m = ( + "It may be that you are requesting the address of a contract that does not" + " get deployed (e.g. `rocketBase`), is deployed multiple times" + " (e.g. `rocketNodeDistributor`)," + " or is not yet deployed on the current chain.\n" + "... or you messed up the name" + ) + await interaction.followup.send(content=m) + + @command() + async def decode_tnx( + self, interaction: Interaction, tnx_hash: str, contract_name: str | None = None + ): + """ + Decode transaction calldata + """ + await interaction.response.defer( + ephemeral=is_hidden_role_controlled(interaction) + ) + tnx: TxData = await w3.eth.get_transaction(HexStr(tnx_hash)) + contract: AsyncContract | None = None + if contract_name: + contract = await rp.get_contract_by_name(contract_name) + elif "to" in tnx: + contract = await rp.get_contract_by_address(tnx["to"]) + assert contract is not None + data = contract.decode_function_input(tnx.get("input")) + await interaction.followup.send(content=f"```Input:\n{data}```") + + # --------- AUTOCOMPLETE --------- # + + @get_address_of_contract.autocomplete("contract") + @get_abi_of_contract.autocomplete("contract") + @decode_tnx.autocomplete("contract_name") + async def match_contract_names( + self, interaction: Interaction, current: str + ) -> list[Choice[str]]: + return [ + Choice(name=name, value=name) + for name in self.contract_names + if current.lower() in name.lower() + ][:25] async def setup(self): diff --git a/rocketwatch/plugins/releases/releases.py b/rocketwatch/plugins/releases/releases.py index d46a0fac..97efdcc3 100644 --- a/rocketwatch/plugins/releases/releases.py +++ b/rocketwatch/plugins/releases/releases.py @@ -1,17 +1,15 @@ import logging import aiohttp +from discord import Interaction +from discord.app_commands import command from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command from rocketwatch import RocketWatch -from utils.cfg import cfg from utils.embeds import Embed from utils.visibility import is_hidden -log = logging.getLogger("releases") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.releases") class Releases(commands.Cog): @@ -19,15 +17,17 @@ def __init__(self, bot: RocketWatch): self.bot = bot self.tag_url = "https://github.com/rocket-pool/smartnode-install/releases/tag/" - @hybrid_command() - async def latest_release(self, ctx: Context): + @command() + async def latest_release(self, interaction: Interaction): """ Get the latest release of Smart Node. """ - await ctx.defer(ephemeral=is_hidden(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) async with aiohttp.ClientSession() as session: - res = await session.get("https://api.github.com/repos/rocket-pool/smartnode-install/tags") + res = await session.get( + "https://api.github.com/repos/rocket-pool/smartnode-install/tags" + ) res = await res.json() latest_release = None for tag in res: @@ -36,8 +36,10 @@ async def latest_release(self, ctx: Context): break e = Embed() - e.add_field(name="Latest Smart Node Release", value=latest_release, inline=False) - await ctx.send(embed=e) + e.add_field( + name="Latest Smart Node Release", value=latest_release, inline=False + ) + await interaction.followup.send(embed=e) async def setup(bot): diff --git a/rocketwatch/plugins/reloader/reloader.py b/rocketwatch/plugins/reloader/reloader.py index 719dd463..8c15ed40 100644 --- a/rocketwatch/plugins/reloader/reloader.py +++ b/rocketwatch/plugins/reloader/reloader.py @@ -1,33 +1,46 @@ +from pathlib import Path + from discord import Interaction -from discord.app_commands import command, guilds, autocomplete, Choice -from discord.ext.commands import Cog +from discord.app_commands import Choice, autocomplete, command, guilds from discord.ext.commands import ( - is_owner, - ExtensionNotLoaded, + Cog, ExtensionAlreadyLoaded, - ExtensionNotFound + ExtensionNotFound, + ExtensionNotLoaded, + is_owner, ) -from pathlib import Path from rocketwatch import RocketWatch -from utils.cfg import cfg +from utils.config import cfg class Reloader(Cog): def __init__(self, bot: RocketWatch): self.bot = bot - - async def _get_loaded_extensions(self, interaction: Interaction, current: str) -> list[Choice[str]]: - loaded = {ext.split(".")[-1] for ext in self.bot.extensions.keys()} - return [Choice(name=plugin, value=plugin) for plugin in loaded if current.lower() in plugin.lower()][:25] - async def _get_unloaded_extensions(self, interaction: Interaction, current: str) -> list[Choice[str]]: - loaded = {ext.split(".")[-1] for ext in self.bot.extensions.keys()} - all = {path.stem for path in Path("plugins").glob('**/*.py')} - return [Choice(name=plugin, value=plugin) for plugin in (all - loaded) if current.lower() in plugin.lower()][:25] + async def _get_loaded_extensions( + self, interaction: Interaction, current: str + ) -> list[Choice[str]]: + loaded = {ext.split(".")[-1] for ext in self.bot.extensions} + return [ + Choice(name=plugin, value=plugin) + for plugin in loaded + if current.lower() in plugin.lower() + ][:25] + + async def _get_unloaded_extensions( + self, interaction: Interaction, current: str + ) -> list[Choice[str]]: + loaded = {ext.split(".")[-1] for ext in self.bot.extensions} + all = {path.stem for path in Path("plugins").glob("**/*.py")} + return [ + Choice(name=plugin, value=plugin) + for plugin in (all - loaded) + if current.lower() in plugin.lower() + ][:25] @command() - @guilds(cfg["discord.owner.server_id"]) + @guilds(cfg.discord.owner.server_id) @is_owner() @autocomplete(module=_get_unloaded_extensions) async def load(self, interaction: Interaction, module: str): @@ -38,12 +51,14 @@ async def load(self, interaction: Interaction, module: str): await interaction.followup.send(content=f"Loaded plugin `{module}`!") await self.bot.sync_commands() except ExtensionAlreadyLoaded: - await interaction.followup.send(content=f"Plugin `{module}` already loaded!") + await interaction.followup.send( + content=f"Plugin `{module}` already loaded!" + ) except ExtensionNotFound: await interaction.followup.send(content=f"Plugin `{module}` not found!") - + @command() - @guilds(cfg["discord.owner.server_id"]) + @guilds(cfg.discord.owner.server_id) @is_owner() @autocomplete(module=_get_loaded_extensions) async def unload(self, interaction: Interaction, module: str): @@ -57,7 +72,7 @@ async def unload(self, interaction: Interaction, module: str): await interaction.followup.send(content=f"Plugin `{module}` not loaded!") @command() - @guilds(cfg["discord.owner.server_id"]) + @guilds(cfg.discord.owner.server_id) @is_owner() @autocomplete(module=_get_loaded_extensions) async def reload(self, interaction: Interaction, module: str): @@ -69,7 +84,7 @@ async def reload(self, interaction: Interaction, module: str): await self.bot.sync_commands() except ExtensionNotLoaded: await interaction.followup.send(content=f"Plugin {module} not loaded!") - + async def setup(bot): await bot.add_cog(Reloader(bot)) diff --git a/rocketwatch/plugins/rewards/rewards.py b/rocketwatch/plugins/rewards/rewards.py index 33bbbb65..d4c8ea5e 100644 --- a/rocketwatch/plugins/rewards/rewards.py +++ b/rocketwatch/plugins/rewards/rewards.py @@ -1,28 +1,22 @@ import logging +from dataclasses import dataclass, replace +from io import BytesIO + import aiohttp -import numpy as np import matplotlib.pyplot as plt - -from io import BytesIO -from discord import File -from discord.app_commands import describe +import numpy as np +from discord import File, Interaction +from discord.app_commands import command, describe from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command - -from typing import Optional -from dataclasses import dataclass from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg +from utils.block_time import ts_to_block from utils.embeds import Embed, resolve_ens +from utils.retry import retry from utils.rocketpool import rp -from utils.retry import retry_async -from utils.block_time import ts_to_block -log = logging.getLogger("rewards") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.rewards") class Rewards(commands.Cog): @@ -41,28 +35,35 @@ class RewardEstimate: eth_rewards: float system_weight: float - @retry_async(tries=3, delay=1) + @retry(tries=3, delay=1) async def _make_request(self, address) -> dict: async with aiohttp.ClientSession() as session: response = await session.get(f"https://sprocketpool.net/api/node/{address}") return await response.json() - async def get_estimated_rewards(self, ctx: Context, address: str) -> Optional[RewardEstimate]: - if not rp.call("rocketNodeManager.getNodeExists", address): - await ctx.send(f"{address} is not a registered node.") + async def get_estimated_rewards( + self, interaction: Interaction, address: str + ) -> RewardEstimate | None: + if not await rp.call("rocketNodeManager.getNodeExists", address): + await interaction.followup.send(f"{address} is not a registered node.") return None try: patches_res = await self._make_request(address) except Exception as e: - await self.bot.report_error(e, ctx) - await ctx.send("Error fetching node data from Sprocket Pool API. Blame Patches.") + await self.bot.report_error(e, interaction) + await interaction.followup.send( + "Error fetching node data from Sprocket Pool API. Blame Patches." + ) return None - data_block = ts_to_block(patches_res["time"]) + data_block = await ts_to_block(patches_res["time"]) rpl_rewards: int = patches_res[address].get("collateralRpl", 0) eth_rewards: int = patches_res[address].get("smoothingPoolEth", 0) - interval_time = rp.call("rocketDAOProtocolSettingsRewards.getRewardsClaimIntervalTime", block=data_block) + interval_time = await rp.call( + "rocketDAOProtocolSettingsRewards.getRewardsClaimIntervalTime", + block=data_block, + ) return Rewards.RewardEstimate( address=address, @@ -73,7 +74,7 @@ async def get_estimated_rewards(self, ctx: Context, address: str) -> Optional[Re end_time=patches_res["startTime"] + interval_time, rpl_rewards=solidity.to_float(rpl_rewards), eth_rewards=solidity.to_float(eth_rewards), - system_weight=solidity.to_float(patches_res["totalNodeWeight"]) + system_weight=solidity.to_float(patches_res["totalNodeWeight"]), ) @staticmethod @@ -86,60 +87,71 @@ def create_embed(title: str, rewards: RewardEstimate) -> Embed: ) return embed - @hybrid_command() + @command() @describe(node_address="address of node to show rewards for") - @describe(extrapolate="whether to extrapolate partial rewards for the entire period") - async def upcoming_rewards(self, ctx: Context, node_address: str, extrapolate: bool = True): + @describe( + extrapolate="whether to extrapolate partial rewards for the entire period" + ) + async def upcoming_rewards( + self, interaction: Interaction, node_address: str, extrapolate: bool = True + ): """ Show estimated RPL and smoothing pool rewards for this period. """ - await ctx.defer(ephemeral=True) - display_name, address = await resolve_ens(ctx, node_address) - if display_name is None: + await interaction.response.defer(ephemeral=True) + display_name, address = await resolve_ens(interaction, node_address) + if (display_name is None) or (address is None): return - rewards = await self.get_estimated_rewards(ctx, address) + rewards = await self.get_estimated_rewards(interaction, address) if rewards is None: return if extrapolate: - registration_time = rp.call("rocketNodeManager.getNodeRegistrationTime", address) + registration_time = await rp.call( + "rocketNodeManager.getNodeRegistrationTime", address + ) reward_start_time = max(registration_time, rewards.start_time) - proj_factor = (rewards.end_time - reward_start_time) / (rewards.data_time - reward_start_time) - rewards.rpl_rewards *= proj_factor - rewards.eth_rewards *= proj_factor + proj_factor = (rewards.end_time - reward_start_time) / ( + rewards.data_time - reward_start_time + ) + rewards = replace( + rewards, + rpl_rewards=rewards.rpl_rewards * proj_factor, + eth_rewards=rewards.eth_rewards * proj_factor, + ) modifier = "Projected" if extrapolate else "Estimated Ongoing" title = f"{modifier} Rewards for {display_name}" embed = self.create_embed(title, rewards) embed.add_field(name="RPL Staking:", value=f"{rewards.rpl_rewards:,.3f} RPL") embed.add_field(name="Smoothing Pool:", value=f"{rewards.eth_rewards:,.3f} ETH") - await ctx.send(embed=embed) + await interaction.followup.send(embed=embed) - @hybrid_command() + @command() @describe( node_address="address of node to simulate rewards for", rpl_stake="amount of staked RPL to simulate", num_leb8="number of 8 ETH minipools to simulate", - num_eb16="number of 16 ETH minipools to simulate" + num_eb16="number of 16 ETH minipools to simulate", ) async def simulate_rewards( - self, - ctx: Context, - node_address: str, - rpl_stake: int = 0, - num_leb8: int = 0, - num_eb16: int = 0 + self, + interaction: Interaction, + node_address: str, + rpl_stake: int = 0, + num_leb8: int = 0, + num_eb16: int = 0, ): """ Simulate RPL rewards for this period """ - await ctx.defer(ephemeral=True) - display_name, address = await resolve_ens(ctx, node_address) - if display_name is None: + await interaction.response.defer(ephemeral=True) + display_name, address = await resolve_ens(interaction, node_address) + if (display_name is None) or (address is None): return - rewards = await self.get_estimated_rewards(ctx, address) + rewards = await self.get_estimated_rewards(interaction, address) if rewards is None: return @@ -149,38 +161,57 @@ async def simulate_rewards( borrowed_eth = (24 * num_leb8) + (16 * num_eb16) data_block: int = rewards.data_block - reward_start_block = ts_to_block(rewards.start_time) + reward_start_block = await ts_to_block(rewards.start_time) - rpl_min: float = solidity.to_float(rp.call("rocketDAOProtocolSettingsNode.getMinimumPerMinipoolStake", block=data_block)) - rpl_ratio = solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice", block=data_block)) - actual_borrowed_eth = solidity.to_float(rp.call("rocketNodeStaking.getNodeETHMatched", address, block=data_block)) - actual_rpl_stake = solidity.to_float(rp.call("rocketNodeStaking.getNodeRPLStake", address, block=data_block)) + rpl_ratio = solidity.to_float( + await rp.call("rocketNetworkPrices.getRPLPrice", block=data_block) + ) + actual_borrowed_eth = solidity.to_float( + await rp.call( + "rocketNodeStaking.getNodeETHBorrowed", address, block=data_block + ) + ) + actual_rpl_stake = solidity.to_float( + await rp.call( + "rocketNodeStaking.getNodeStakedRPL", address, block=data_block + ) + ) - inflation_rate: int = rp.call("rocketTokenRPL.getInflationIntervalRate", block=data_block) - inflation_interval: int = rp.call("rocketTokenRPL.getInflationIntervalTime", block=data_block) - num_inflation_intervals: int = (rewards.end_time - rewards.start_time) // inflation_interval - total_supply: int = rp.call("rocketTokenRPL.totalSupply", block=reward_start_block) + inflation_rate: int = await rp.call( + "rocketTokenRPL.getInflationIntervalRate", block=data_block + ) + inflation_interval: int = await rp.call( + "rocketTokenRPL.getInflationIntervalTime", block=data_block + ) + num_inflation_intervals: int = ( + rewards.end_time - rewards.start_time + ) // inflation_interval + total_supply: int = await rp.call( + "rocketTokenRPL.totalSupply", block=reward_start_block + ) period_inflation: int = total_supply - for i in range(num_inflation_intervals): + for _i in range(num_inflation_intervals): period_inflation = solidity.to_int(period_inflation * inflation_rate) period_inflation -= total_supply def node_weight(_stake: float, _borrowed_eth: float) -> float: rpl_value = _stake * rpl_ratio collateral_ratio = (rpl_value / _borrowed_eth) if _borrowed_eth > 0 else 0 - if collateral_ratio < rpl_min: - return 0.0 - elif collateral_ratio <= 0.15: + if collateral_ratio <= 0.15: return 100 * rpl_value else: - return (13.6137 + 2 * np.log(100 * collateral_ratio - 13)) * _borrowed_eth + return ( + 13.6137 + 2 * np.log(100 * collateral_ratio - 13) + ) * _borrowed_eth def rewards_at(_stake: float, _borrowed_eth: float) -> float: weight = node_weight(_stake, _borrowed_eth) base_weight = node_weight(actual_rpl_stake, _borrowed_eth) new_system_weight = rewards.system_weight + weight - base_weight - return solidity.to_float(0.7 * period_inflation * weight / new_system_weight) + return solidity.to_float( + 0.7 * period_inflation * weight / new_system_weight + ) fig, ax = plt.subplots(figsize=(5, 2.5)) ax.grid() @@ -194,10 +225,12 @@ def rewards_at(_stake: float, _borrowed_eth: float) -> float: cur_color, cur_label, cur_ls = "#eb8e55", "current", "solid" sim_color, sim_label, sim_ls = "darkred", "simulated", "dashed" - def draw_reward_curve(_color: str, _label: Optional[str], _line_style: str, _borrowed_eth: float) -> None: + def draw_reward_curve( + _color: str, _label: str | None, _line_style: str, _borrowed_eth: float + ) -> None: step_size = max(1, (x_max - x_min) // 1000) x = np.arange(x_min, x_max, step_size, dtype=int) - y = np.array([rewards_at(x, _borrowed_eth) for x in x]) + y = np.array([rewards_at(int(x), _borrowed_eth) for x in x]) ax.plot(x, y, color=_color, linestyle=_line_style, label=_label) def plot_point(_pt_color: str, _pt_label: str, _x: int) -> None: @@ -209,7 +242,7 @@ def plot_point(_pt_color: str, _pt_label: str, _x: int) -> None: (_x, _y), textcoords="offset points", xytext=(5, -10 if _y > 0 else 5), - ha="left" + ha="left", ) plot_point(cur_color, cur_label, actual_rpl_stake) @@ -224,7 +257,9 @@ def plot_point(_pt_color: str, _pt_label: str, _x: int) -> None: elif borrowed_eth > 0: draw_reward_curve(sim_color, None, sim_ls, borrowed_eth) else: - await ctx.send("Empty node. Choose another one or specify the minipool count.") + await interaction.followup.send( + "Empty node. Choose another one or specify the minipool count." + ) return def formatter(_x, _pos) -> str: @@ -241,19 +276,21 @@ def formatter(_x, _pos) -> str: ax.set_ylabel("rewards") ax.xaxis.set_major_formatter(formatter) - y_min = min(rewards_at(x_min, borrowed_eth), rewards_at(x_min, actual_borrowed_eth)) + y_min = min( + rewards_at(x_min, borrowed_eth), rewards_at(x_min, actual_borrowed_eth) + ) _, y_max = ax.get_ylim() ax.set_ylim((y_min, y_max)) handles, labels = ax.get_legend_handles_labels() - by_label = dict(zip(labels, handles)) - plt.legend(by_label.values(), by_label.keys(), loc="lower right") + by_label = dict(zip(labels, handles, strict=False)) + ax.legend(by_label.values(), by_label.keys(), loc="lower right") fig.tight_layout() img = BytesIO() fig.savefig(img, format="png") img.seek(0) - plt.close() + plt.close(fig) sim_info = [] if rpl_stake > 0: @@ -270,7 +307,7 @@ def formatter(_x, _pos) -> str: embed.set_image(url="attachment://rewards.png") f = File(img, filename="rewards.png") - await ctx.send(embed=embed, files=[f]) + await interaction.followup.send(embed=embed, files=[f]) img.close() diff --git a/rocketwatch/plugins/rocksolid/rocksolid.py b/rocketwatch/plugins/rocksolid/rocksolid.py new file mode 100644 index 00000000..679435ba --- /dev/null +++ b/rocketwatch/plugins/rocksolid/rocksolid.py @@ -0,0 +1,166 @@ +import logging +from datetime import datetime, timedelta +from io import BytesIO + +import matplotlib.pyplot as plt +import numpy as np +from discord import File, Interaction +from discord.app_commands import command +from discord.ext.commands import Cog +from eth_typing import BlockNumber +from matplotlib.dates import DateFormatter +from pymongo import InsertOne + +from rocketwatch import RocketWatch +from utils import solidity +from utils.block_time import block_to_ts, ts_to_block +from utils.embeds import Embed, el_explorer_url +from utils.event_logs import get_logs +from utils.rocketpool import rp +from utils.shared_w3 import w3 +from utils.visibility import is_hidden + +cog_id = "rocksolid" +log = logging.getLogger(f"rocketwatch.{cog_id}") + + +class RockSolid(Cog): + def __init__(self, bot: RocketWatch): + self.bot = bot + self.deployment_block = 23237366 + + async def _fetch_asset_updates(self) -> list[tuple[int, float]]: + vault_contract = await rp.get_contract_by_name("RockSolidVault") + + if db_entry := (await self.bot.db.last_checked_block.find_one({"_id": cog_id})): + last_checked_block = db_entry["block"] + else: + last_checked_block = self.deployment_block + + b_from = BlockNumber(last_checked_block + 1) + b_to = await w3.eth.get_block_number() + + updates = [] + + async for doc in self.bot.db.rocksolid.find({}): + updates.append((doc["time"], doc["assets"])) + + db_operations = [] + for event_log in await get_logs( + vault_contract.events.TotalAssetsUpdated, b_from, b_to + ): + ts = await block_to_ts(event_log["blockNumber"]) + assets = solidity.to_float(event_log["args"]["totalAssets"]) + updates.append((ts, assets)) + db_operations.append(InsertOne({"time": ts, "assets": assets})) + + async with self.bot.db.client.start_session() as session: # noqa: SIM117 + async with await session.start_transaction(): + if db_operations: + await self.bot.db.rocksolid.bulk_write(db_operations) + await self.bot.db.last_checked_block.replace_one( + {"_id": cog_id}, {"_id": cog_id, "block": b_to}, upsert=True + ) + + return updates + + @command() + async def rocksolid(self, interaction: Interaction): + """ + Summary of RockSolid rETH vault stats. + """ + await interaction.response.defer(ephemeral=is_hidden(interaction)) + + current_block = await w3.eth.get_block_number() + now = await block_to_ts(current_block) + + async def get_eth_rate(block_number: int) -> int: + block_number = max(block_number, self.deployment_block) + reth_value = await rp.call( + "RockSolidVault.convertToAssets", 10**18, block=block_number + ) + return await rp.call( + "rocketTokenRETH.getEthValue", reth_value, block=block_number + ) + + current_eth_rate = await get_eth_rate(current_block) + + async def get_apy(days: int) -> float | None: + reference_block = await ts_to_block( + now - timedelta(days=days).total_seconds() + ) + if reference_block < self.deployment_block: + return None + return ( + (current_eth_rate / await get_eth_rate(reference_block) - 1) + * (365 / days) + * 100 + ) + + apy_7d = await get_apy(days=7) + apy_30d = await get_apy(days=30) + apy_90d = await get_apy(days=90) + + tvl_reth = solidity.to_float(await rp.call("RockSolidVault.totalAssets")) + tvl_rock_reth = solidity.to_float(await rp.call("RockSolidVault.totalSupply")) + + asset_updates: list[tuple[int, float]] = await self._fetch_asset_updates() + current_date = datetime.fromtimestamp(asset_updates[0][0]).date() - timedelta( + days=1 + ) + current_assets = 0.0 + + x, y = [], [] + for ts, assets in asset_updates: + update_date = datetime.fromtimestamp(ts).date() + while current_date < update_date: + x.append(current_date) + y.append(current_assets) + current_date += timedelta(days=1) + + current_date = update_date + current_assets = assets + + x.append(current_date) + y.append(current_assets) + + fig, ax = plt.subplots(figsize=(6, 2)) + ax.grid() + + x_arr = np.array(x) + ax.plot(x_arr, y, color="#50b1f7") + ax.xaxis.set_major_formatter(DateFormatter("%b %d")) + ax.set_ylabel("AUM (rETH)") + ax.set_xlim((x_arr[0], x_arr[-1])) + ax.set_ylim((y[0], y[-1] * 1.05)) + + img = BytesIO() + fig.tight_layout() + fig.savefig(img, format="png") + img.seek(0) + plt.close(fig) + + ca_reth = await rp.get_address_by_name("rocketTokenRETH") + ca_rock_reth = await rp.get_address_by_name("RockSolidVault") + + embed = Embed(title="<:rocksolid:1425091714267480158> RockSolid rETH Vault") + embed.add_field(name="7d APY", value=f"{apy_7d:.2f}%" if apy_7d else "-") + embed.add_field(name="30d APY", value=f"{apy_30d:.2f}%" if apy_30d else "-") + embed.add_field(name="90d APY", value=f"{apy_90d:.2f}%" if apy_90d else "-") + embed.add_field( + name="TVL", + value=f"`{tvl_reth:,.2f}` {await el_explorer_url(ca_reth, name=' rETH')}", + ) + embed.add_field( + name="Supply", + value=f"`{tvl_rock_reth:,.2f}` {await el_explorer_url(ca_rock_reth, name=' rock.rETH')}", + ) + embed.set_image(url="attachment://rocksolid-tvl.png") + + await interaction.followup.send( + embed=embed, file=File(img, "rocksolid-tvl.png") + ) + + +async def setup(bot): + await bot.add_cog(RockSolid(bot)) diff --git a/rocketwatch/plugins/rpips/rpips.py b/rocketwatch/plugins/rpips/rpips.py index 870301f7..53d4c2b0 100644 --- a/rocketwatch/plugins/rpips/rpips.py +++ b/rocketwatch/plugins/rpips/rpips.py @@ -1,67 +1,62 @@ import logging -import requests -from typing import Optional, Any +import aiohttp +from aiocache import cached from bs4 import BeautifulSoup -from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command -from discord.app_commands import Choice, describe -from cachetools.func import ttl_cache +from discord import Interaction +from discord.app_commands import Choice, command, describe +from discord.ext.commands import Cog from rocketwatch import RocketWatch -from utils.cfg import cfg from utils.embeds import Embed from utils.retry import retry -log = logging.getLogger("rpips") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.rpips") -class RPIPs(commands.Cog): +class RPIPs(Cog): def __init__(self, bot: RocketWatch): self.bot = bot - @hybrid_command() + @command() @describe(name="RPIP name") - async def rpip(self, ctx: Context, name: str): + async def rpip(self, interaction: Interaction, name: str): """Show information about a specific RPIP.""" - await ctx.defer() + await interaction.response.defer() embed = Embed() - embed.set_author(name="🔗 Data from rpips.rocketpool.net", url="https://rpips.rocketpool.net") + embed.set_author( + name="🔗 Data from rpips.rocketpool.net", url="https://rpips.rocketpool.net" + ) - rpips_by_name: dict[str, RPIPs.RPIP] = {rpip.full_title: rpip for rpip in self.get_all_rpips()} + rpips_by_name: dict[str, RPIPs.RPIP] = { + rpip.full_title: rpip for rpip in await self.get_all_rpips() + } if rpip := rpips_by_name.get(name): + details = await rpip.fetch_details() embed.title = name embed.url = rpip.url - embed.description = rpip.description + embed.description = details["description"] - if len(rpip.authors) == 1: - embed.add_field(name="Author", value=rpip.authors[0]) + authors = details["authors"] + if len(authors) == 1: + embed.add_field(name="Author", value=authors[0]) else: - embed.add_field(name="Authors", value=", ".join(rpip.authors)) + embed.add_field(name="Authors", value=", ".join(authors)) embed.add_field(name="Status", value=rpip.status) - embed.add_field(name="Created", value=rpip.created) - embed.add_field(name="Discussion Link", value=rpip.discussion, inline=False) + embed.add_field(name="Created", value=details["created"]) + embed.add_field( + name="Discussion Link", value=details["discussion"], inline=False + ) else: embed.description = "No matching RPIPs." - await ctx.send(embed=embed) + await interaction.followup.send(embed=embed) class RPIP: - __slots__ = ( - "title", - "number", - "status", - "type", - "authors", - "created", - "discussion", - "description" - ) + __slots__ = ("number", "status", "title") - def __init__(self, title: str, number: int, status:str): + def __init__(self, title: str, number: int, status: str): self.title = title self.number = number self.status = status @@ -69,27 +64,44 @@ def __init__(self, title: str, number: int, status:str): def __str__(self) -> str: return self.full_title - @ttl_cache(ttl=300) + @cached(ttl=300, key_builder=lambda _, rpip: rpip.number) @retry(tries=3, delay=1) - def __fetch_data(self) -> dict[str, Optional[str | list[str]]]: - soup = BeautifulSoup(requests.get(self.url).text, "html.parser") - metadata = {} - - for field in soup.main.find("table", {"class": "rpip-preamble"}).find_all("tr"): - match field_name := field.th.text: - case "Discussion": - metadata[field_name] = field.td.a["href"] - case "Author": - metadata[field_name] = [a.text for a in field.td.find_all("a")] - case _: - metadata[field_name] = field.td.text - + async def fetch_details(self) -> dict[str, str | list[str] | None]: + async with ( + aiohttp.ClientSession() as session, + session.get(self.url) as resp, + ): + html = await resp.text() + + soup = BeautifulSoup(html, "html.parser") + if not soup.main: + return {} + + preamble = soup.main.find("table", {"class": "rpip-preamble"}) + if not preamble: + return {} + + metadata: dict[str, str | list[str]] = {} + for field in preamble.find_all("tr"): + if field.th and field.td: + match field_name := field.th.text: + case "Discussion": + if field.td.a: + metadata[field_name] = field.td.a["href"] + case "Author": + metadata[field_name] = [ + a.text for a in field.td.find_all("a") + ] + case _: + metadata[field_name] = field.td.text + + description_tag = soup.find("big", {"class": "rpip-description"}) return { "type": metadata.get("Type"), "authors": metadata.get("Author"), "created": metadata.get("Created"), "discussion": metadata.get("Discussion"), - "description": soup.find("big", {"class": "rpip-description"}).text + "description": description_tag.text if description_tag else None, } @property @@ -100,33 +112,41 @@ def full_title(self) -> str: def url(self) -> str: return f"https://rpips.rocketpool.net/RPIPs/RPIP-{self.number}" - def __getattr__(self, key: str) -> Any: - try: - return self.__fetch_data()[key] or "N/A" - except KeyError: - raise AttributeError(f"RPIP has no attribute '{key}'") - @rpip.autocomplete("name") - async def _get_rpip_names(self, ctx: Context, current: str) -> list[Choice[str]]: + async def _get_rpip_names( + self, interaction: Interaction, current: str + ) -> list[Choice[str]]: choices = [] - for rpip in self.get_all_rpips(): + for rpip in await self.get_all_rpips(): if current.lower() in (name := rpip.full_title).lower(): choices.append(Choice(name=name, value=name)) return choices[:-26:-1] @staticmethod - @ttl_cache(ttl=60) + @cached(ttl=60) @retry(tries=3, delay=1) - def get_all_rpips() -> list['RPIPs.RPIP']: - html_doc = requests.get("https://rpips.rocketpool.net/all").text - soup = BeautifulSoup(html_doc, "html.parser") - rpips: list['RPIPs.RPIP'] = [] - + async def get_all_rpips() -> list["RPIPs.RPIP"]: + async with ( + aiohttp.ClientSession() as session, + session.get("https://rpips.rocketpool.net/all") as resp, + ): + html = await resp.text() + + soup = BeautifulSoup(html, "html.parser") + if not soup.table: + return [] + + rpips: list[RPIPs.RPIP] = [] for row in soup.table.find_all("tr", recursive=False): - title = row.find("td", {"class": "title"}).text.strip() - rpip_num = int(row.find("td", {"class": "rpipnum"}).text) - status = row.find("td", {"class": "status"}).text.strip() - rpips.append(RPIPs.RPIP(title, rpip_num, status)) + title_td = row.find("td", {"class": "title"}) + num_td = row.find("td", {"class": "rpipnum"}) + status_td = row.find("td", {"class": "status"}) + if title_td and num_td and status_td: + rpips.append( + RPIPs.RPIP( + title_td.text.strip(), int(num_td.text), status_td.text.strip() + ) + ) return rpips diff --git a/rocketwatch/plugins/rpl/rpl.py b/rocketwatch/plugins/rpl/rpl.py index cb7cdb55..b5458248 100644 --- a/rocketwatch/plugins/rpl/rpl.py +++ b/rocketwatch/plugins/rpl/rpl.py @@ -1,187 +1,139 @@ import logging from io import BytesIO -import humanize import matplotlib.pyplot as plt -import numpy as np -from discord import File +from discord import File, Interaction +from discord.app_commands import command from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command -from motor.motor_asyncio import AsyncIOMotorClient from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg from utils.embeds import Embed -from utils.block_time import ts_to_block from utils.rocketpool import rp -from utils.shared_w3 import w3 from utils.visibility import is_hidden -log = logging.getLogger("rpl") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.rpl") class RPL(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch - @hybrid_command() - async def rpl_apr(self, ctx: Context): + @command() + async def staked_rpl(self, interaction: Interaction): """ - Show the RPL APR. + Show the amount of RPL staked """ - await ctx.defer(ephemeral=is_hidden(ctx)) - e = Embed() - - reward_duration = rp.call("rocketRewardsPool.getClaimIntervalTime") - total_rpl_staked = await self.db.node_operators_new.aggregate([ - { - '$group': { - '_id' : 'out', - 'total_effective_rpl_stake': { - '$sum': '$effective_rpl_stake' - } - } - } - ]).next() - total_rpl_staked = total_rpl_staked["total_effective_rpl_stake"] - - # track down the rewards for node operators from the last reward period - contract = rp.get_contract_by_name("rocketVault") - m = ts_to_block(rp.call("rocketRewardsPool.getClaimIntervalTimeStart")) - events = contract.events["TokenDeposited"].getLogs(argument_filters={ - "by": w3.soliditySha3( - ["string", "address"], - ["rocketMerkleDistributorMainnet", rp.get_address_by_name("rocketTokenRPL")]) - }, fromBlock=m - 10000, toBlock=m + 10000) - perc_nodes = solidity.to_float(rp.call("rocketRewardsPool.getClaimingContractPerc", "rocketClaimNode")) - perc_odao = solidity.to_float(rp.call("rocketRewardsPool.getClaimingContractPerc", "rocketClaimTrustedNode")) - node_operator_rewards = solidity.to_float(events[0].args.amount) * (perc_nodes / (perc_nodes + perc_odao)) - if not e: - raise Exception("no rpl deposit event found") - - xmin = total_rpl_staked * 0.66 - xmax = total_rpl_staked * 1.33 - x = np.linspace(xmin, xmax) - - def apr_curve(staked): - return (node_operator_rewards / staked) / (reward_duration / 60 / 60 / 24) * 365 - - apr = apr_curve(total_rpl_staked) - y = apr_curve(x) - fig = plt.figure() - plt.plot(x, y, color=str(e.color)) - plt.xlim(xmin, xmax) - plt.ylim(apr_curve(xmax) * 0.9, apr_curve(xmin) * 1.1) - plt.plot(total_rpl_staked, apr, 'bo') - plt.annotate(f"{apr:.2%}", (total_rpl_staked, apr), - textcoords="offset points", xytext=(-10, -5), ha='right') - plt.annotate(f"{total_rpl_staked / 1000000:.2f} million staked", - (total_rpl_staked, apr), textcoords="offset points", xytext=(10, -5), ha='left') - plt.grid() - - ax = plt.gca() - ax.xaxis.set_major_formatter(lambda x, _: "{:.1f}m".format(x / 1000000)) - ax.yaxis.set_major_formatter("{x:.2%}") - ax.set_ylabel("APR") - ax.set_xlabel("RPL Staked") - fig.tight_layout() + await interaction.response.defer(ephemeral=is_hidden(interaction)) + + rpl_supply = solidity.to_float(await rp.call("rocketTokenRPL.totalSupply")) + legacy_staked_rpl = solidity.to_float( + await rp.call("rocketNodeStaking.getTotalLegacyStakedRPL") + ) + megapool_staked_rpl = solidity.to_float( + await rp.call("rocketNodeStaking.getTotalMegapoolStakedRPL") + ) + staked_rpl = legacy_staked_rpl + megapool_staked_rpl + unstaking_rpl = ( + await ( + await self.bot.db.node_operators.aggregate( + [ + { + "$group": { + "_id": "out", + "total_unstaking_rpl_": {"$sum": "$rpl.unstaking"}, + } + } + ] + ) + ).next() + )["total_unstaking_rpl_"] + unstaked_rpl = rpl_supply - staked_rpl - unstaking_rpl + + def fmt(v): + if v >= 1_000_000: + return f"{v / 1_000_000:.2f}M" + if v >= 1_000: + return f"{v / 1_000:.1f}K" + return f"{v:.0f}" + + sizes = [legacy_staked_rpl, megapool_staked_rpl, unstaking_rpl, unstaked_rpl] + labels = ["Legacy", "Megapools", "Unstaking", "Unstaked"] + colors = ["#CC4400", "#FF6B00", "#D2B48C", "#808080"] + + total = sum(sizes) + + def autopct(pct): + return f"{fmt(pct / 100 * total)} ({pct:.1f}%)" + + fig, ax = plt.subplots() + ax.pie( + sizes, + labels=labels, + colors=colors, + autopct=autopct, + startangle=90, + wedgeprops={"linewidth": 0.5, "edgecolor": "white"}, + ) img = BytesIO() - fig.savefig(img, format='png') + fig.tight_layout() + fig.savefig(img, format="png") img.seek(0) - plt.close() + plt.close(fig) - e.title = "RPL APR Graph" - e.set_image(url="attachment://graph.png") - f = File(img, filename="graph.png") - await ctx.send(embed=e, files=[f]) + embed = Embed() + embed.title = "Staked RPL" + embed.set_image(url="attachment://graph.png") + file = File(img, filename="graph.png") + + await interaction.followup.send(embed=embed, file=file) img.close() - @hybrid_command() - async def effective_rpl_staked(self, ctx: Context): - """ - Show the effective RPL staked by users - """ - await ctx.defer(ephemeral=is_hidden(ctx)) - e = Embed() - # get total RPL staked - total_rpl_staked = solidity.to_float(rp.call("rocketNodeStaking.getTotalRPLStake")) - e.add_field(name="Total RPL Staked:", value=f"{humanize.intcomma(total_rpl_staked, 2)} RPL", inline=False) - # get effective RPL staked - effective_rpl_stake = await self.db.node_operators_new.aggregate([ - { - '$group': { - '_id' : 'out', - 'total_effective_rpl_stake': { - '$sum': '$effective_rpl_stake' - } - } - } - ]).next() - effective_rpl_stake = effective_rpl_stake["total_effective_rpl_stake"] # calculate percentage staked - percentage_staked = effective_rpl_stake / total_rpl_staked - e.add_field(name="Effective RPL Staked:", value=f"{humanize.intcomma(effective_rpl_stake, 2)} RPL " - f"({percentage_staked:.2%})", inline=False) - # get total supply - total_rpl_supply = solidity.to_float(rp.call("rocketTokenRPL.totalSupply")) - # calculate total staked as a percentage of total supply - percentage_of_total_staked = total_rpl_staked / total_rpl_supply - e.add_field(name="Percentage of RPL Supply Staked:", value=f"{percentage_of_total_staked:.2%}", inline=False) - await ctx.send(embed=e) - - @hybrid_command() - async def withdrawable_rpl(self, - ctx: Context): + @command() + async def withdrawable_rpl(self, interaction: Interaction): """ Show the available liquidity at different RPL/ETH prices """ - await ctx.defer(ephemeral=is_hidden(ctx)) - e = Embed() - img = BytesIO() - - data = await self.db.node_operators_new.aggregate([ - { - '$match': { - 'staking_minipool_count': { - '$ne': 0 - } - } - }, { - '$project': { - 'ethStake': { - '$multiply': [ - '$effective_node_share', { - '$multiply': [ - '$staking_minipool_count', 32 + await interaction.response.defer(ephemeral=is_hidden(interaction)) + + data = await ( + await self.bot.db.node_operators.aggregate( + [ + {"$match": {"staking_minipool_count": {"$ne": 0}}}, + { + "$project": { + "eth_stake": { + "$multiply": [ + "$effective_node_share", + {"$multiply": ["$staking_minipool_count", 32]}, ] - } - ] + }, + "rpl_stake": "$rpl.legacy_stake", + } }, - 'rpl_stake': 1 - } - } - ]).to_list(length=None) - rpl_eth_price = solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice")) + ] + ) + ).to_list() + rpl_eth_price = solidity.to_float( + await rp.call("rocketNetworkPrices.getRPLPrice") + ) # calculate withdrawable RPL at various RPL ETH prices # i/10 is the ratio of the price checked to the actual RPL ETH price free_rpl_liquidity = {} - max_collateral = solidity.to_float(rp.call("rocketDAOProtocolSettingsNode.getMaximumPerMinipoolStake")) + max_collateral = solidity.to_float( + await rp.call("rocketDAOProtocolSettingsNode.getMinimumLegacyRPLStake") + ) current_withdrawable_rpl = 0 for i in range(1, 31): - - test_ratio = (i / 10) + test_ratio = i / 10 rpl_eth_test_price = rpl_eth_price * test_ratio liquid_rpl = 0 for node in data: - - eth_stake = node["ethStake"] + eth_stake = node["eth_stake"] rpl_stake = node["rpl_stake"] # if there are no pools, then all the RPL can be withdrawn @@ -196,43 +148,57 @@ async def withdrawable_rpl(self, if collateral_percentage < max_collateral: continue - liquid_rpl += ((collateral_percentage - max_collateral) / collateral_percentage) * rpl_stake + liquid_rpl += ( + (collateral_percentage - max_collateral) / collateral_percentage + ) * rpl_stake free_rpl_liquidity[i] = (rpl_eth_test_price, liquid_rpl) if test_ratio == 1: current_withdrawable_rpl = liquid_rpl # break the tuples into lists to plot - x, y = zip(*list(free_rpl_liquidity.values())) + x, y = zip(*list(free_rpl_liquidity.values()), strict=False) + + embed = Embed() # plot the data - plt.plot(x, y, color=str(e.color)) - plt.plot(rpl_eth_price, current_withdrawable_rpl, 'bo') - plt.xlim(min(x), max(x)) - - plt.annotate(f"{rpl_eth_price:.4f}", (rpl_eth_price, current_withdrawable_rpl), - textcoords="offset points", xytext=(-10, -5), ha='right') - plt.annotate(f"{current_withdrawable_rpl / 1000000:.2f} million RPL withdrawable", - (rpl_eth_price, current_withdrawable_rpl), textcoords="offset points", xytext=(10, -5), - ha='left') - plt.grid() - - ax = plt.gca() + fig, ax = plt.subplots() + ax.plot(x, y, color=str(embed.color)) + ax.plot(rpl_eth_price, current_withdrawable_rpl, "bo") + ax.set_xlim(min(x), max(x)) + + ax.annotate( + f"{rpl_eth_price:.4f}", + (rpl_eth_price, current_withdrawable_rpl), + textcoords="offset points", + xytext=(-10, -5), + ha="right", + ) + ax.annotate( + f"{current_withdrawable_rpl / 1000000:.2f} million RPL withdrawable", + (rpl_eth_price, current_withdrawable_rpl), + textcoords="offset points", + xytext=(10, -5), + ha="left", + ) + ax.grid() + ax.set_ylabel("Withdrawable RPL") ax.set_xlabel("RPL / ETH ratio") - ax.yaxis.set_major_formatter(lambda x, _: "{:.1f}m".format(x / 1000000)) - ax.xaxis.set_major_formatter(lambda x, _: "{:.4f}".format(x)) + ax.yaxis.set_major_formatter(lambda x, _: f"{x / 1000000:.1f}m") + ax.xaxis.set_major_formatter(lambda x, _: f"{x:.4f}") - plt.tight_layout() - plt.savefig(img, format='png') + img = BytesIO() + fig.tight_layout() + fig.savefig(img, format="png") img.seek(0) - plt.close() + plt.close(fig) - e.title = "Available RPL Liquidity" - e.set_image(url="attachment://graph.png") + embed.title = "Available RPL Liquidity" + embed.set_image(url="attachment://graph.png") f = File(img, filename="graph.png") - await ctx.send(embed=e, files=[f]) + await interaction.followup.send(embed=embed, files=[f]) img.close() diff --git a/rocketwatch/plugins/scam_detection/scam_detection.py b/rocketwatch/plugins/scam_detection/scam_detection.py new file mode 100644 index 00000000..1b9a4719 --- /dev/null +++ b/rocketwatch/plugins/scam_detection/scam_detection.py @@ -0,0 +1,911 @@ +import asyncio +import contextlib +import json +import logging +from datetime import UTC, datetime, timedelta +from urllib import parse + +import regex as re +from anyascii import anyascii +from cachetools import TTLCache +from discord import ( + AppCommandType, + ButtonStyle, + Color, + DeletedReferencedMessage, + Emoji, + File, + Guild, + Interaction, + Member, + Message, + PartialEmoji, + RawBulkMessageDeleteEvent, + RawMessageDeleteEvent, + RawThreadDeleteEvent, + Reaction, + Thread, + User, + errors, + ui, +) +from discord.abc import Messageable +from discord.app_commands import ContextMenu, command, guilds +from discord.ext.commands import Cog + +from rocketwatch import RocketWatch +from utils.config import cfg +from utils.embeds import Embed +from utils.file import TextFile + +log = logging.getLogger("rocketwatch.scam_detection") + + +class ScamDetection(Cog): + class Color: + ALERT = Color.from_rgb(255, 0, 0) + WARN = Color.from_rgb(255, 165, 0) + OK = Color.from_rgb(0, 255, 0) + + @staticmethod + def is_reputable(user: Member) -> bool: + return any( + ( + user.id == cfg.discord.owner.user_id, + user.id in cfg.rocketpool.support.user_ids, + {role.id for role in user.roles} & set(cfg.rocketpool.support.role_ids), + user.guild_permissions.moderate_members, + ) + ) + + class RemovalVoteView(ui.View): + THRESHOLD = 5 + + def __init__(self, plugin: "ScamDetection", reportable: Message | Thread): + super().__init__(timeout=None) + self.plugin = plugin + self.reportable = reportable + self.safu_votes: set[int] = set() + + @ui.button(label="Mark Safu", style=ButtonStyle.blurple) + async def mark_safe(self, interaction: Interaction, button: ui.Button) -> None: + if interaction.message is None: + return + + log.info( + f"User {interaction.user.id} marked message {interaction.message.id} as safe" + ) + + reportable_repr = type(self.reportable).__name__.lower() + if interaction.user.id in self.safu_votes: + log.debug( + f"User {interaction.user.id} already voted on {reportable_repr}" + ) + await interaction.response.send_message( + content="You already voted!", ephemeral=True + ) + return + + if isinstance(interaction.user, Member) and interaction.user.is_timed_out(): + log.debug( + f"Timed-out user {interaction.user.id} tried to vote on {self.reportable}" + ) + return + + reported_user = None + if isinstance(self.reportable, Message): + reported_user = self.reportable.author + db_filter = {"type": "message", "message_id": self.reportable.id} + required_lock = self.plugin._message_report_lock + elif isinstance(self.reportable, Thread): + reported_user = self.reportable.owner + db_filter = {"type": "thread", "channel_id": self.reportable.id} + required_lock = self.plugin._thread_report_lock + else: + log.warning(f"Unknown reportable type {type(self.reportable)}") + return + + if interaction.user == reported_user: + log.debug( + f"User {interaction.user.id} tried to mark their own {reportable_repr} as safe" + ) + await interaction.response.send_message( + content=f"You can't vote on your own {reportable_repr}!", + ephemeral=True, + ) + return + + self.safu_votes.add(interaction.user.id) + + if isinstance(interaction.user, Member) and ScamDetection.is_reputable( + interaction.user + ): + user_repr = interaction.user.mention + elif len(self.safu_votes) >= self.THRESHOLD: + user_repr = "the community" + else: + button.label = f"Mark Safu ({len(self.safu_votes)}/{self.THRESHOLD})" + await interaction.response.edit_message(view=self) + return + + await interaction.message.delete() + + async with required_lock: + report = await self.plugin.bot.db.scam_reports.find_one(db_filter) + if report is not None: + await self.plugin._update_report( + report, f"This has been marked as safe by {user_repr}." + ) + await self.plugin.bot.db.scam_reports.update_one( + db_filter, {"$set": {"warning_id": None}} + ) + await interaction.response.send_message( + content="Warning removed!", ephemeral=True + ) + + def __init__(self, bot: RocketWatch): + self.bot = bot + self._message_report_lock = asyncio.Lock() + self._thread_report_lock = asyncio.Lock() + self._user_report_lock = asyncio.Lock() + self._message_react_cache: TTLCache[ + int, dict[PartialEmoji | Emoji | str, set[User | Member]] + ] = TTLCache(maxsize=1000, ttl=300) + self._thread_creation_messages: set[int] = set() + self.markdown_link_pattern = re.compile( + r"(?<=\[)([^/\] ]*).+?(?<=\(https?:\/\/)([^/\)]*)" + ) + self.basic_url_pattern = re.compile( + r"https?:\/\/?([/\\@\-_0-9a-zA-Z]+\.)+[\\@\-_0-9a-zA-Z]+" + ) + self.invite_pattern = re.compile( + r"((discord(app)?\.com\/(invite|oauth2))|((dsc|dcd|discord)\.gg))(\\|\/)(?P[a-zA-Z0-9]+)" + ) + # Detects URLs broken across lines (with optional blockquote "> " prefixes) to evade filters + _brk = r"(?:[\s>\u2060\u200b\ufeff]*\n[\s>\u2060\u200b\ufeff]*)" # newline with optional blockquote/zero-width chars + _ws = r"[\s>]*" + self.obfuscated_url_pattern = re.compile( + rf"<{_ws}ht{_brk}tp|" # tp + rf"<{_ws}ma{_ws}i{_brk}l{_ws}t{_ws}o|" # i\n> L\n> To (mailto) + rf" None: + self.bot.tree.remove_command( + self.message_report_menu.name, type=self.message_report_menu.type + ) + self.bot.tree.remove_command( + self.user_report_menu.name, type=self.user_report_menu.type + ) + + async def _get_report_channel(self) -> Messageable: + channel = await self.bot.get_or_fetch_channel( + cfg.discord.channels["report_scams"] + ) + assert isinstance(channel, Messageable) + return channel + + @staticmethod + def _get_message_content(message: Message) -> str: + text = "" + if message.content: + content = message.content + content = content.replace("\n> ", "") + content = content.replace("\n", "") + text += content + "\n" + if message.embeds: + for embed in message.embeds: + text += f"---\n Embed: {embed.title}\n{embed.description}\n---\n" + + text = parse.unquote(text) + text = anyascii(text) + text = text.lower() + return text + + async def _generate_message_report( + self, message: Message, reason: str + ) -> tuple[Embed, Embed, File] | None: + try: + message = await message.channel.fetch_message(message.id) + if isinstance(message, DeletedReferencedMessage): + return None + except errors.NotFound: + return None + + if await self.bot.db.scam_reports.find_one( + {"type": "message", "message_id": message.id} + ): + log.info(f"Found existing report for message {message.id} in database") + return None + + warning = Embed(title="🚨 Likely Scam Detected") + warning.color = self.Color.ALERT + warning.description = f"**Reason**: {reason}\n" + + report = warning.copy() + warning.set_footer( + text="This message will be deleted once the suspicious message is removed." + ) + + report.description = warning.description + ( + "\n" + f"User ID: `{message.author.id}` ({message.author.mention})\n" + f"Message ID: `{message.id}` ({message.jump_url})\n" + f"Channel ID: `{message.channel.id}` ({message.channel.jump_url})\n" + "\n" + "Original message has been attached as a file.\n" + "Please review and take appropriate action." + ) + + message_structure = json.dumps( + { + "content": message.content, + **( + { + "embeds": [ + { + "title": e.title, + "description": e.description, + } + for e in message.embeds + ] + } + if message.embeds + else {} + ), + }, + indent=2, + ) + attachment = TextFile(message_structure, filename="message.json") + + return warning, report, attachment + + async def _generate_thread_report( + self, thread: Thread, reason: str + ) -> tuple[Embed, Embed] | None: + if await self.bot.db.scam_reports.find_one( + {"type": "thread", "channel_id": thread.id} + ): + log.info(f"Found existing report for thread {thread.id} in database") + return None + + warning = Embed(title="🚨 Likely Scam Detected") + warning.color = self.Color.ALERT + warning.description = f"**Reason**: {reason}\n" + + report = warning.copy() + warning.set_footer( + text=( + "There is no ticket system for support on this server.\n" + "Don't engage in conversation outside of the public #support channel.\n" + "Ignore this thread and any invites or DMs you may receive." + ) + ) + thread_owner = await self.bot.get_or_fetch_user(thread.owner_id) + report.description = warning.description + ( + "\n" + f"Thread Name: `{thread.name}`\n" + f"User ID: `{thread_owner.id}` ({thread_owner.mention})\n" + f"Thread ID: `{thread.id}` ({thread.jump_url})\n" + "\n" + "Please review and take appropriate action." + ) + return warning, report + + async def _add_message_report_to_db( + self, + message: Message, + reason: str, + warning_msg: Message | None, + report_msg: Message, + ) -> None: + await self.bot.db.scam_reports.insert_one( + { + "type": "message", + "guild_id": message.guild.id if message.guild else None, + "channel_id": message.channel.id, + "message_id": message.id, + "user_id": message.author.id, + "reason": reason, + "content": message.content, + "embeds": [embed.to_dict() for embed in message.embeds], + "warning_id": warning_msg.id if warning_msg else None, + "report_id": report_msg.id, + "user_banned": False, + "removed": False, + } + ) + + async def report_message(self, message: Message, reason: str) -> None: + async with self._message_report_lock: + if not (components := await self._generate_message_report(message, reason)): + return None + + warning, report, attachment = components + + try: + view = self.RemovalVoteView(self, message) + warning_msg = await message.reply( + embed=warning, view=view, mention_author=False + ) + except errors.Forbidden: + warning_msg = None + log.warning(f"Failed to send warning message in reply to {message.id}") + + report_channel = await self._get_report_channel() + report_msg = await report_channel.send(embed=report, file=attachment) + await self._add_message_report_to_db( + message, reason, warning_msg, report_msg + ) + + async def manual_message_report( + self, interaction: Interaction, message: Message + ) -> None: + await interaction.response.defer(ephemeral=True) + + if message.author.bot: + return await interaction.followup.send( + content="Bot messages can't be reported." + ) + + if message.author == interaction.user: + return await interaction.followup.send( + content="Did you just report yourself?" + ) + + async with self._message_report_lock: + reason = f"Manual report by {interaction.user.mention}" + if not (components := await self._generate_message_report(message, reason)): + return await interaction.followup.send( + content="Failed to report message. It may have already been reported or deleted." + ) + + warning, report, attachment = components + + report_channel = await self._get_report_channel() + report_msg = await report_channel.send(embed=report, file=attachment) + + moderator = await self.bot.get_or_fetch_user( + cfg.rocketpool.support.moderator_id + ) + view = self.RemovalVoteView(self, message) + warning_msg = await message.reply( + content=f"{moderator.mention} {report_msg.jump_url}", + embed=warning, + view=view, + mention_author=False, + ) + await self._add_message_report_to_db( + message, reason, warning_msg, report_msg + ) + await interaction.followup.send(content="Thanks for reporting!") + + def _discord_invite(self, message: Message) -> str | None: + # Only check message content, not embeds (legit videos/links have discord invites in embeds) + if not message.content: + return None + content = message.content + content = parse.unquote(content) + content = anyascii(content) + content = content.lower() + if match := self.invite_pattern.search(content): + link = match.group(0) + trusted_domains = [ + "youtu.be", + "youtube.com", + "tenor.com", + "giphy.com", + "imgur.com", + "bluesky.app", + ] + if not any(domain in link for domain in trusted_domains): + return "Invite to external server" + return None + + def _tap_on_this(self, message: Message) -> str | None: + txt = self._get_message_content(message) + keywords = [("tap on", "click on"), "proper"] + return "Tap on deez nuts nerd" if self.__txt_contains(txt, keywords) else None + + def _obfuscated_url(self, message: Message) -> str | None: + if not message.content: + return None + + default_reason = "URL obfuscation" + # Line-broken protocol/scheme + if self.obfuscated_url_pattern.search(message.content): + return default_reason + # Fullwidth/homoglyph dots in domain + if self.homoglyph_url_pattern.search(message.content): + return default_reason + # Heavily percent-encoded ASCII in URL (encoding ASCII is suspicious; non-ASCII like Cyrillic is normal) + if re.search(r"https?://[^\s]*(?:%[0-7][0-9a-fA-F]){5}", message.content): + return default_reason + # Markdown link where visible text looks like a different domain than the actual URL + content = parse.unquote(message.content) + content = anyascii(content).lower() + for m in self.markdown_link_pattern.findall(content): + if "." in m[0] and m[0].rstrip(".") != m[1].rstrip("."): + return "Visible text changes link domain" + + return None + + def _ticket_system(self, message: Message) -> str | None: + txt = self._get_message_content(message) + if not self.basic_url_pattern.search(txt): + return None + + default_reason = "There is no ticket system in this server." + + # High-confidence scam indicators (don't need URL trust check) + strong_keywords = ( + ( + "support team", + "supp0rt", + "🎫", + ":ticket:", + "🎟️", + ":tickets:", + "m0d", + "tlcket", + "relate your issue", + ), + [("relay"), ("query", "question", "inquiry")], + [("instant", "live"), "chat"], + [("submit"), ("question", "issue", "query")], + ) + content_only = txt.split("---")[0] + # Auto-generated embeds from video platforms may contain event/ticket + # language (e.g. YouTube 🎫 TICKETS) — only check content for those. + rich_embed_domains = ("youtube.com", "youtu.be", "twitch.tv") + content_urls = list(self.basic_url_pattern.finditer(content_only)) + if content_urls and all( + any(d in m.group(0) for d in rich_embed_domains) for m in content_urls + ): + strong_check_text = re.sub(r"https?://\S+", "", content_only) + else: + strong_check_text = re.sub(r"https?://\S+", "", txt) + if self.__txt_contains(strong_check_text, strong_keywords): + return default_reason + + # Short directive messages with a URL ("ask here", "get help here") + content_only = txt.split("---")[0].strip() # exclude embeds + if len(content_only) < 120 and self.basic_url_pattern.search(txt): + directives = ("ask here", "get help", "help here", "click here", "go here") + if any(d in content_only for d in directives): + return default_reason + + # Weaker keywords: only check short messages (long technical discussions cause false positives) + content_txt = self._get_message_content(message) + content_only_txt = content_txt.split("---")[0] # strip embed text + if len(content_only_txt) > 500: + return None + + ticket_keywords = [ + ("support", "open", "create", "raise", "raisse"), + "ticket", + ] + # For short messages, also check full text (including embeds) for ticket keywords. + # Scammers use embeds (via X posts, Discord invites) to carry ticket/support language. + # Only use the ticket pattern here; the contact+admin pattern is too broad for embed text + # (e.g. "administration" in news articles matches "admin"). + if len(content_only_txt) <= 200 and self.__txt_contains(txt, ticket_keywords): + return default_reason + + trusted_url_domains = ( + "youtu.be", + "youtube.com", + "twitter.com", + "x.com", + "fxtwitter.com", + "fixvx.com", + "fxbsky.app", + "reddit.com", + "github.com", + "etherscan.io", + "beaconcha.in", + "rocketpool.net", + "docs.rocketpool.net", + "rocketpool.support", + "xcancel.com", + "steely-test.org", + "validatorqueue.com", + "checkpointz", + "discord.com", + "forms.gle", + "google.com", + ) + content_urls = list(self.basic_url_pattern.finditer(content_only_txt)) + if not content_urls or all( + any(domain in m.group(0) for domain in trusted_url_domains) + for m in content_urls + ): + return None + + weak_keywords = ( + [("support", "open", "create", "raise", "raisse"), "ticket"], + [ + ( + "contact", + "reach out", + "report", + [("talk", "speak"), ("to", "with")], + "ask", + ), + ("admin", "mod", "administrator", "moderator", "team"), + ], + ) + if self.__txt_contains(content_only_txt, weak_keywords): + return default_reason + + return None + + @staticmethod + def __txt_contains(txt: str, kw: list | tuple | str) -> bool: + match kw: + case str(): + return kw in txt + case tuple(): + return any(map(lambda w: ScamDetection.__txt_contains(txt, w), kw)) + case list(): + return all(map(lambda w: ScamDetection.__txt_contains(txt, w), kw)) + return False + + def _suspicious_link(self, message: Message) -> str | None: + txt = self._get_message_content(message) + if "http" not in txt: + return None + hosting_domains = ("pages.dev", "web.app", "vercel.app") + if any(d in txt for d in hosting_domains) and re.search( + r"\b(?:mint|opensea|airdrop|claim|reward|free)\b", txt + ): + return "The linked website is most likely a wallet drainer" + return None + + def _suspicious_x_account(self, message: Message) -> str | None: + if not message.content: + return None + suspicious_keywords = ("support", "ticket", "helpdesk", "assist") + for m in self.x_url_pattern.finditer(message.content): + username = m.group(1).lower() + if any(kw in username for kw in suspicious_keywords): + return "Link to suspicious X account" + return None + + def _bio_redirect(self, message: Message) -> str | None: + if not message.content or len(message.content) > 300: + return None + txt = self._get_message_content(message) + if any(kw in txt for kw in ("my bio", "my icon", "my profile", "my pfp")): + return "Redirection to malicious profile link" + return None + + def _spam_wall(self, message: Message) -> str | None: + if not message.content or len(message.content) < 100: + return None + content = message.content + # Spoiler wall: many spoiler tags with minimal visible content + if content.count("||") >= 20: + stripped = re.sub(r"\|\||[\s\u200b_]|https?://\S+", "", content).strip() + if len(stripped) < 10: + return "Spoiler wall spam" + # Invisible character wall: mostly blank/invisible characters + visible = re.sub( + r"[\s\u2800\u200b\u200c\u200d\u2060\ufeff\U000e0000-\U000e007f]", + "", + content, + ) + if len(visible) < 10 and len(content) > 200: + return "Invisible character spam" + return None + + async def _reaction_spam(self, reaction: Reaction, user: User) -> str | None: + # user reacts to their own message multiple times in quick succession to draw attention + # check if user is a bot + if user.bot: + log.debug(f"Ignoring reaction by bot {user.id}") + return None + + # check if the reaction is by the same user that created the message + if reaction.message.author != user: + log.debug(f"Ignoring reaction by non-author {user.id}") + return None + + # check if the message is new enough (we ignore any reactions on messages older than 5 minutes) + if (reaction.message.created_at - datetime.now(UTC)) > timedelta(minutes=5): + log.debug(f"Ignoring reaction on old message {reaction.message.id}") + return None + + # get all reactions on message + reactions = self._message_react_cache.get(reaction.message.id) + if reactions is None: + reactions = {} + for msg_reaction in reaction.message.reactions: + reactions[msg_reaction.emoji] = { + user async for user in msg_reaction.users() + } + self._message_react_cache[reaction.message.id] = reactions + elif reaction.emoji not in reactions: + reactions[reaction.emoji] = {user} + else: + reactions[reaction.emoji].add(user) + + reaction_count = len( + [r for r in reactions.values() if user in r and len(r) == 1] + ) + log.debug(f"{reaction_count} reactions on message {reaction.message.id}") + # if there are 8 reactions done by the author of the message, report it + return "Reaction spam by message author" if (reaction_count >= 8) else None + + @Cog.listener() + async def on_message(self, message: Message) -> None: + log.debug( + f"Message(id={message.id}, author={message.author}, channel={message.channel}," + f' content="{message.content}", embeds={message.embeds})' + ) + + if message.author.bot: + log.warning("Ignoring message sent by bot") + return + + if message.guild is None: + return + + if message.guild.id != cfg.rocketpool.support.server_id: + log.warning(f"Ignoring message in {message.guild.id})") + return + + if isinstance(message.author, Member) and self.is_reputable(message.author): + log.warning(f"Ignoring message sent by trusted user ({message.author})") + return + + checks = [ + self._obfuscated_url, + self._ticket_system, + self._suspicious_x_account, + self._suspicious_link, + self._discord_invite, + self._tap_on_this, + self._bio_redirect, + self._spam_wall, + ] + + for check in checks: + if reason := check(message): + await self.report_message(message, reason) + return + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + await self.on_message(after) + + @Cog.listener() + async def on_reaction_add(self, reaction: Reaction, user: User) -> None: + if ( + reaction.message.guild is None + or reaction.message.guild.id != cfg.rocketpool.support.server_id + ): + log.warning(f"Ignoring reaction in {reaction.message.guild}") + + return + + checks = [self._reaction_spam(reaction, user)] + for reason in await asyncio.gather(*checks): + if reason: + await self.report_message(reaction.message, reason) + return + + @Cog.listener() + async def on_raw_message_delete(self, event: RawMessageDeleteEvent) -> None: + await self._on_message_delete(event.message_id) + + @Cog.listener() + async def on_raw_bulk_message_delete( + self, event: RawBulkMessageDeleteEvent + ) -> None: + await asyncio.gather( + *[self._on_message_delete(msg_id) for msg_id in event.message_ids] + ) + + async def _on_message_delete(self, message_id: int) -> None: + await self._check_thread_starter_deleted(message_id) + async with self._message_report_lock: + db_filter = {"type": "message", "message_id": message_id, "removed": False} + if not (report := await self.bot.db.scam_reports.find_one(db_filter)): + return + + channel = await self.bot.get_or_fetch_channel(report["channel_id"]) + assert isinstance(channel, Messageable) + with contextlib.suppress( + errors.NotFound, errors.Forbidden, errors.HTTPException + ): + message = await channel.fetch_message(report["warning_id"]) + await message.delete() + + await self._update_report(report, "Original message has been deleted.") + await self.bot.db.scam_reports.update_one( + db_filter, {"$set": {"warning_id": None, "removed": True}} + ) + + async def _check_thread_starter_deleted(self, message_id: int) -> None: + if message_id not in self._thread_creation_messages: + return + + self._thread_creation_messages.remove(message_id) + + try: + thread = await self.bot.get_or_fetch_channel(message_id) + except (errors.NotFound, errors.Forbidden): + return + + if isinstance(thread, Thread): + await self.report_thread(thread, "Attempt to hide thread from main channel") + + @Cog.listener() + async def on_member_ban(self, guild: Guild, user: User) -> None: + async with ( + self._message_report_lock, + self._thread_report_lock, + self._user_report_lock, + ): + reports = await self.bot.db.scam_reports.find( + {"guild_id": guild.id, "user_id": user.id, "user_banned": False} + ).to_list() + for report in reports: + await self._update_report(report, "User has been banned.") + await self.bot.db.scam_reports.update_one( + report, {"$set": {"user_banned": True}} + ) + + async def _update_report(self, report: dict, note: str) -> None: + report_channel = await self._get_report_channel() + try: + message = await report_channel.fetch_message(report["report_id"]) + embed = message.embeds[0] + embed.description = (embed.description or "") + f"\n\n**{note}**" + embed.color = ( + self.Color.WARN if (embed.color == self.Color.ALERT) else self.Color.OK + ) + await message.edit(embed=embed) + except Exception as e: + await self.bot.report_error(e) + + async def report_thread(self, thread: Thread, reason: str) -> None: + async with self._thread_report_lock: + if not (components := await self._generate_thread_report(thread, reason)): + return None + + warning, report = components + + try: + view = self.RemovalVoteView(self, thread) + warning_msg = await thread.send(embed=warning, view=view) + except errors.Forbidden: + log.warning(f"Failed to send warning message in thread {thread.id}") + warning_msg = None + + report_channel = await self._get_report_channel() + report_msg = await report_channel.send(embed=report) + await self.bot.db.scam_reports.insert_one( + { + "type": "thread", + "guild_id": thread.guild.id, + "channel_id": thread.id, + "user_id": thread.owner_id, + "reason": reason, + "content": thread.name, + "warning_id": warning_msg.id if warning_msg else None, + "report_id": report_msg.id, + "user_banned": False, + "removed": False, + } + ) + + @Cog.listener() + async def on_thread_create(self, thread: Thread) -> None: + if thread.guild.id == cfg.rocketpool.support.server_id: + # system message and thread share the same ID + self._thread_creation_messages.add(thread.id) + + @Cog.listener() + async def on_raw_thread_delete(self, event: RawThreadDeleteEvent) -> None: + db_filter = {"type": "thread", "channel_id": event.thread_id, "removed": False} + async with self._thread_report_lock: + if report := await self.bot.db.scam_reports.find_one(db_filter): + await self._update_report(report, "Thread has been deleted.") + await self.bot.db.scam_reports.update_one( + db_filter, {"$set": {"warning_id": None, "removed": True}} + ) + + @command() + @guilds(cfg.rocketpool.support.server_id) + async def report_user(self, interaction: Interaction, user: Member) -> None: + """Generate a suspicious user report and send it to the report channel""" + await self.manual_user_report(interaction, user) + + async def manual_user_report(self, interaction: Interaction, user: Member) -> None: + await interaction.response.defer(ephemeral=True) + + if user.bot: + return await interaction.followup.send(content="Bots can't be reported.") + + if user == interaction.user: + return await interaction.followup.send( + content="Did you just report yourself?" + ) + + async with self._user_report_lock: + reason = f"Manual report by {interaction.user.mention}" + if not (report := await self._generate_user_report(user, reason)): + return await interaction.followup.send( + content="Failed to report user. They may have already been reported or banned." + ) + + report_channel = await self._get_report_channel() + report_msg = await report_channel.send(embed=report) + await self.bot.db.scam_reports.insert_one( + { + "type": "user", + "guild_id": user.guild.id, + "user_id": user.id, + "reason": reason, + "content": user.display_name, + "warning_id": None, + "report_id": report_msg.id, + "user_banned": False, + } + ) + await interaction.followup.send(content="Thanks for reporting!") + + async def _generate_user_report(self, user: Member, reason: str) -> Embed | None: + if not isinstance(user, Member): + return None + + if await self.bot.db.scam_reports.find_one( + {"type": "user", "guild_id": user.guild.id, "user_id": user.id} + ): + log.info(f"Found existing report for user {user.id} in database") + return None + + report = Embed(title="🚨 Suspicious User Detected") + report.color = self.Color.ALERT + report.description = f"**Reason**: {reason}\n" + report.description += ( + "\n" + f"Name: `{user.display_name}`\n" + f"ID: `{user.id}` ({user.mention})\n" + f"Roles: [{', '.join(role.mention for role in user.roles[1:])}]\n" + "\n" + "Please review and take appropriate action." + ) + report.set_thumbnail(url=user.display_avatar.url) + return report + + +async def setup(bot): + await bot.add_cog(ScamDetection(bot)) diff --git a/rocketwatch/plugins/scam_warning/scam_warning.py b/rocketwatch/plugins/scam_warning/scam_warning.py index 859449d0..b46153df 100644 --- a/rocketwatch/plugins/scam_warning/scam_warning.py +++ b/rocketwatch/plugins/scam_warning/scam_warning.py @@ -1,31 +1,37 @@ import logging -from datetime import timedelta, datetime +from datetime import datetime, timedelta from discord import errors +from discord.abc import Messageable from discord.ext import commands -from motor.motor_asyncio import AsyncIOMotorClient from rocketwatch import RocketWatch -from utils.cfg import cfg +from utils.config import cfg from utils.embeds import Embed - -log = logging.getLogger("scam_warning") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.scam_warning") class ScamWarning(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).get_database("rocketwatch") - self.channel_ids = set(cfg["rocketpool.dm_warning.channels"]) + self.channel_ids = set(cfg.rocketpool.dm_warning.channels) self.inactivity_cooldown = timedelta(days=90) self.failure_cooldown = timedelta(days=1) async def send_warning(self, user) -> None: - support_channel = await self.bot.get_or_fetch_channel(cfg["rocketpool.support.channel_id"]) - report_channel = await self.bot.get_or_fetch_channel(cfg["discord.channels.report_scams"]) - resource_channel = await self.bot.get_or_fetch_channel(cfg["discord.channels.resources"]) + support_channel = await self.bot.get_or_fetch_channel( + cfg.rocketpool.support.channel_id + ) + report_channel = await self.bot.get_or_fetch_channel( + cfg.discord.channels["report_scams"] + ) + resource_channel = await self.bot.get_or_fetch_channel( + cfg.discord.channels["resources"] + ) + assert isinstance(support_channel, Messageable) + assert isinstance(report_channel, Messageable) + assert isinstance(resource_channel, Messageable) embed = Embed() embed.title = "**Stay Safe on Rocket Pool Discord**" @@ -80,7 +86,9 @@ async def on_message(self, message) -> None: return msg_time = message.created_at.replace(tzinfo=None) - db_entry = (await self.db.scam_warning.find_one({"_id": message.author.id})) or {} + db_entry = ( + await self.bot.db.scam_warning.find_one({"_id": message.author.id}) + ) or {} cooldown_end = datetime.fromtimestamp(0) if last_failure_time := db_entry.get("last_failure"): @@ -97,10 +105,14 @@ async def on_message(self, message) -> None: log.info(f"Unable to DM {message.author}, skipping warning.") last_failure_time = msg_time - await self.db.scam_warning.replace_one( + await self.bot.db.scam_warning.replace_one( {"_id": message.author.id}, - {"_id": message.author.id, "last_message": msg_time, "last_failure": last_failure_time}, - upsert=True + { + "_id": message.author.id, + "last_message": msg_time, + "last_failure": last_failure_time, + }, + upsert=True, ) diff --git a/rocketwatch/plugins/sleep/sleep.py b/rocketwatch/plugins/sleep/sleep.py deleted file mode 100644 index fcae7663..00000000 --- a/rocketwatch/plugins/sleep/sleep.py +++ /dev/null @@ -1,438 +0,0 @@ -import datetime -import logging -from io import BytesIO - -import matplotlib.colors as mcolors -import matplotlib.pyplot as plt -import pytz -import requests -from discord import File -from discord.ext import commands -from discord.ext.commands import Context, hybrid_command -from homeassistant_api import Client -from matplotlib import dates - -from rocketwatch import RocketWatch -from utils.cfg import cfg -from utils.embeds import Embed -from utils.visibility import is_hidden - -log = logging.getLogger("sleep") -log.setLevel(cfg["log_level"]) - - -def get_color_hsv(value): - # Ensure the value is within [0, 1] - value -= 0.5 - value *= 2 - value = max(0, min(1, value)) - - # Map the value to the hue in HSV (red to green) - hue = value / 3 # Red is at 0, green is at 1/3 in HSV space - color_hsv = (hue, 1, 0.8) # Full saturation and value - - # Convert HSV to RGB - return mcolors.hsv_to_rgb(color_hsv) - -class Oura(commands.Cog): - def __init__(self, bot: RocketWatch): - self.bot = bot - self.calendar_url = cfg["oura.calendar_url"] - - @hybrid_command() - async def sleep_schedule(self, ctx: Context): - await ctx.defer(ephemeral=is_hidden(ctx)) - e = Embed(title="Invis's Sleep Schedule") - current_date = datetime.datetime.now() - tz = pytz.timezone("Europe/Vienna") - start_date = current_date - datetime.timedelta(days=150) - # make start date timezone aware - start_date = tz.localize(start_date) - end_date = current_date - # make end date timezone aware - end_date = tz.localize(end_date) - res = requests.get("https://api.ouraring.com/v2/usercollection/sleep", - params={"start_date": start_date.strftime("%Y-%m-%d"), - "end_date" : (end_date + datetime.timedelta(days=1)).strftime("%Y-%m-%d")}, - headers={"Authorization": f"Bearer {cfg['oura.secret']}"}) - if res.status_code != 200: - e.description = "Error fetching sleep data" - await ctx.send(embed=e) - return - data = res.json() - if len(data["data"]) == 0: - e.description = "No sleep data found" - await ctx.send(embed=e) - return - - res2 = requests.get("https://api.ouraring.com/v2/usercollection/daily_sleep", - params={"start_date": start_date.strftime("%Y-%m-%d"), - "end_date" : (end_date + datetime.timedelta(days=1)).strftime("%Y-%m-%d")}, - headers={"Authorization": f"Bearer {cfg['oura.secret']}"}) - if res2.status_code != 200: - e.description = "Error fetching sleep data" - await ctx.send(embed=e) - return - data2 = res2.json() - score_mapping = dict() - if len(data2["data"]) != 0: - for kasd in data2["data"]: - score_mapping[kasd["day"]] = kasd["score"] - daily_sleep = { - (start_date + datetime.timedelta(days=i)).strftime("%Y-%m-%d"): [] - for i in range((end_date - start_date).days + 1)} - - for sleep in reversed(data["data"]): - if sleep["type"] == "rest": - continue - # skip if sleep_duration is less than 30 minutes. units are in seconds - if sleep["total_sleep_duration"] < 30 * 60: - continue - score = score_mapping.get(sleep["day"]) - hr = sleep["lowest_heart_rate"] - hrv = sleep["average_hrv"] - temperature = sleep["readiness"]["temperature_deviation"] - sd = datetime.datetime.fromisoformat(sleep["bedtime_start"]).astimezone(tz=tz) - log.info(f"start date: {sd}") - # the start day is the next day if we are past 12pm, otherwise it is the current day - start_day_r = sd + datetime.timedelta(days=1) if sd.hour >= 18 else sd - # format to string - start_day = start_day_r.strftime("%Y-%m-%d") - ed = datetime.datetime.fromisoformat(sleep["bedtime_end"]).astimezone(tz=tz) - log.info(f"end date: {ed}") - # the end day is the next day if we are past 12pm, otherwise it is the current day - ed_r = ed + datetime.timedelta(days=1) if ed.hour >= 18 else ed - # format to string - end_day = ed_r.strftime("%Y-%m-%d") - thresh = datetime.datetime(year=ed.year, month=ed.month, day=ed.day, hour=18, - tzinfo=ed.tzinfo) - # Define virtual day start at 18:00 - virtual_day_start = datetime.datetime.combine(sd.date(), datetime.time(18, 0)) - virtual_day_start = tz.localize(virtual_day_start) - if sd < virtual_day_start: - virtual_day_start -= datetime.timedelta(days=1) - virtual_day_end = virtual_day_start + datetime.timedelta(days=1) - start_day = virtual_day_start.strftime("%Y-%m-%d") - if start_day not in daily_sleep: - daily_sleep[start_day] = [] - if end_day not in daily_sleep: - daily_sleep[end_day] = [] - # weekday based on start date - weekday = datetime.datetime.fromisoformat(start_day).weekday() - stats = sleep["sleep_phase_5_min"] - # Initialize sleep segments - sleep_segments = [] - - total_duration = ed - sd - total_seconds = total_duration.total_seconds() - cumulative_seconds = 0 - - # Check for overflow into previous virtual day - if sd < virtual_day_start: - dur_prev = virtual_day_start - sd - if dur_prev.total_seconds() > 0: - stats_prev_len = int(len(stats) * (dur_prev.total_seconds() / total_seconds)) - stats_prev = stats[:stats_prev_len] - prev_day = (virtual_day_start - datetime.timedelta(days=1)).strftime("%Y-%m-%d") - if prev_day not in daily_sleep: - daily_sleep[prev_day] = [] - daily_sleep[prev_day].append({ - "relative_start": sd - (virtual_day_start - datetime.timedelta(days=1)), - "duration": dur_prev, - "weekday": weekday, - "sleep_stats": stats_prev, - "readiness": score, - "hr": hr, - "hrv": hrv, - "temperature": temperature - }) - stats = stats[stats_prev_len:] # Remove used stats - cumulative_seconds += dur_prev.total_seconds() - sd = virtual_day_start # Adjust start time - - # Now compute duration in current virtual day - dur_current = min(ed, virtual_day_end) - sd - if dur_current.total_seconds() > 0: - stats_current_len = int(len(stats) * (dur_current.total_seconds() / (total_seconds - cumulative_seconds))) - stats_current = stats[:stats_current_len] - if start_day not in daily_sleep: - daily_sleep[start_day] = [] - daily_sleep[start_day].append({ - "relative_start": sd - virtual_day_start, - "duration": dur_current, - "weekday": weekday, - "sleep_stats": stats_current, - "readiness": score, - "hr": hr, - "hrv": hrv, - "temperature": temperature - }) - stats = stats[stats_current_len:] - cumulative_seconds += dur_current.total_seconds() - sd = virtual_day_end # Adjust start time - -# Check for overflow into next virtual day - if ed > virtual_day_end: - dur_next = ed - virtual_day_end - if dur_next.total_seconds() > 0: - stats_next = stats # Remaining stats - next_day = virtual_day_end.strftime("%Y-%m-%d") - if next_day not in daily_sleep: - daily_sleep[next_day] = [] - daily_sleep[next_day].append({ - "relative_start": datetime.timedelta(), - "duration": dur_next, - "weekday": weekday, - "sleep_stats": stats_next, - "readiness": score, - "hr": hr, - "hrv": hrv, - "temperature": temperature - }) - # sort by date - daily_sleep = dict(sorted(daily_sleep.items(), key=lambda x: x[0])) - # plot, one large plot that has the sleep data and a small thin plot that shows the hr&hrv data below - fig, ax = plt.subplots(3, 1, figsize=(15, 10), gridspec_kw={'height_ratios': [6, 1, 1]}, sharex=True) - # dark mode - # create horizontal dark gray line at midnight and noon - ax[0].axhline(y=18, color="#808080", linewidth=1) - ax[0].axhline(y=18 - 12, color="#808080", linewidth=1) - calendar_data = await self.get_calendar_data() - # render calendar data if they are within the last 180 days - if calendar_data is not None: - for day, data in calendar_data.items(): - for d in data: - bottom = ((24 * 60 * 60) - d[ - "relative_start"].total_seconds() - d["duration"].total_seconds()) / 3600 - width = d["duration"].total_seconds() / 3600 - try: - i = list(daily_sleep.keys()).index(day) - except ValueError: - continue - ax[0].bar(i, width, bottom=bottom, color="#AAAAAA", width=1, alpha=0.25) - for i, (day, sleeps) in reversed(list(enumerate(daily_sleep.items()))): - for sleep in sleeps: - color = "gray" - if sleep["readiness"] is not None: - color = get_color_hsv(sleep["readiness"] / 100) - bottom = ((24 * 60 * 60) - sleep[ - "relative_start"].total_seconds() - sleep["duration"].total_seconds()) / 3600 - width = sleep["duration"].total_seconds() / 3600 - current_bottom = bottom + width - for state in sleep["sleep_stats"]: - current_bottom -= (width / len(sleep["sleep_stats"])) - w = 0.9 - match int(state): - case 4: - w = 0.4 - case 3: - w = 0.4 - case 2: - w = 0.9 - case 1: - w = 0.9 - ax[0].bar(i, width / len(sleep["sleep_stats"]), bottom=current_bottom, color=color, - alpha=0.2 if state == "4" else 1, width=w) - # set x axis labels, only every 7th day - ax[0].set_xticks(range(len(daily_sleep) - 1, 0, -14)) - ax[0].set_xticklabels([day[2:] for i, (day, _) in enumerate(reversed(daily_sleep.items())) if i % 14 == 0]) - # set y axis labels - ax[0].set_yticks(range(0, 25, 2)) - ax[0].set_yticklabels([f"{i}:00" if i >= 0 else f"{24 + i}:00" for i in range(18, -7, -2)]) - # set y limit - ax[0].set_ylim(0, 24) - # set x limit - ax[0].set_xlim(0, len(daily_sleep)) - # grid - ax[0].grid(True) - ax[0].set_axisbelow(True) - # set title - ax[0].set_title("Invis's Sleep Schedule") - - x = [] - y_hr = [] - y_hrv = [] - y_temperature = [] - for i, (day, sleeps) in reversed(list(enumerate(daily_sleep.items()))): - if len(sleeps) == 0: - continue - min_hr = min(sleep["hr"] for sleep in sleeps) - min_hrv = min(sleep["hrv"] for sleep in sleeps) - s = list(sleep["temperature"] for sleep in sleeps if (sleep["temperature"] != [] and sleep["temperature"] is not None)) - max_temperature = max(s) if s else 0 - x.append(i) - y_hr.append(min_hr) - y_hrv.append(min_hrv) - y_temperature.append(max_temperature) - # fill the area between the the line and zero - #ax[1].plot(x, y_temperature, color="gray", alpha=0.7) - # draw as bars instead of line, red if positive, blue if negative - ax[1].bar(x, y_temperature, color=["red" if i >= 0 else "blue" for i in y_temperature], alpha=0.5) - # show x.5 ticks on y axis for axis 1 - min_t, max_t = min(y_temperature), max(y_temperature) - # we want 4 ticks evenly spaecd between min and max - ax[1].set_yticks([round(min_t + (max_t - min_t) / 3 * i,2) for i in range(4)]) - # blue area, negative - #ax[1].fill_between(x, y_temperature, color="blue", alpha=0.25, where=[i <= 0 for i in y_temperature], interpolate=True) - # red area, positive - #ax[1].fill_between(x, y_temperature, color="red", alpha=0.25, where=[i >= 0 for i in y_temperature], interpolate=True) - ax[2].plot(x, y_hr, color="black", alpha=0.7) - min_hr, max_hr = min(y_hr), max(y_hr) - ax[2].set_yticks([round(min_hr + (max_hr - min_hr) / 3 * i,0) for i in range(4)]) - # add y axis label - ax[2].set_ylabel("HR") - ax3 = ax[2].twinx() - # add y axis label - ax3.set_ylabel("HRV") - ax3.plot(x, y_hrv, color="green", alpha=0.7) - min_hrv, max_hrv = min(y_hrv), max(y_hrv) - ax3.set_yticks([round(min_hrv + (max_hrv - min_hrv) / 3 * i,0) for i in range(4)]) - ax[2].legend(["HR"], loc="lower left") - ax3.legend(["HRV"], loc="upper left") - ax[1].legend(["Temperature"], loc="upper left") - # make axis 2 right side axis color green with 0.7 alpha - # unit °C on y axis - - # reduce padding - plt.tight_layout() - - img = BytesIO() - fig.savefig(img, format='png', dpi=250) - img.seek(0) - plt.close() - - e.set_image(url="attachment://sleep.png") - buf = File(img, filename="sleep.png") - # send image - await ctx.send(file=buf, embed=e) - - @hybrid_command() - async def temperature(self, ctx: Context): - await ctx.defer(ephemeral=is_hidden(ctx)) - client = Client(cfg["homeassistant.url"], cfg["homeassistant.token"], use_async=True) - entity = await client.async_get_entity(entity_id="sensor.aranet_4_home_temperature") - temp = client.async_get_entity_histories( - entities=[entity], - start_timestamp=datetime.datetime.now(tz=pytz.timezone("Europe/Vienna")).replace(tzinfo=None) - datetime.timedelta(days=7), - end_timestamp=datetime.datetime.now(tz=pytz.timezone("Europe/Vienna")).replace(tzinfo=None) - ) - e = Embed(title="Indoor Temperature Chart") - # plot - with plt.rc_context({'font.size': 24}): - fig, ax = plt.subplots(figsize=(15, 10)) - x = [] - y = [] - async for entity in temp: - for state in entity.states: - try: - f = float(state.state) - except ValueError: - continue - x.append(state.last_updated.astimezone(pytz.timezone("Europe/Vienna"))) - y.append(f) - # make line thicker - ax.plot(x, y, linewidth=4) - #ax.set_ylabel("Temperature") - #ax.set_xlabel("Time") - # temp range 15-35°C - ax.set_ylim(15, 35) - ax.grid() - # set x_axis min to x[0] but leave max to None - ax.set_xlim(x[0], None) - # format x axis as DD.MM HH:MM - ax.xaxis.set_major_formatter( - dates.ConciseDateFormatter(ax.xaxis.get_major_locator())) - # format y axis as °C - ax.yaxis.set_major_formatter('{x:.0f}°C') - # get the color that was used to plot the line - color = ax.get_lines()[0].get_color() - # add a big hollow point at the latest point - ax.scatter(x[-1], y[-1], s=1000, facecolor='none', edgecolor="black", linewidth=4, alpha=0.3) - # add a vertical line at the latest point - ax.axvline(x[-1], color="black", linewidth=2, linestyle="--", alpha=0.3) - # reduce padding - plt.tight_layout() - img = BytesIO() - fig.savefig(img, format='png', dpi=100) - img.seek(0) - plt.close() - e.set_image(url="attachment://temperature.png") - buf = File(img, filename="temperature.png") - e.description = f"{y[-1]} °C (as of )" - # send image - await ctx.send(file=buf, embed=e) - - - # replace get_calendar_data with home assistant variant - async def get_calendar_data(self): - return None - client = Client(cfg["homeassistant.url"], cfg["homeassistant.token"], use_async=True) - work_periods = [] - last_state = None - async for zone in client.async_get_logbook_entries( - filter_entities="device_tracker.pixel_8_pro_2", - start_timestamp=datetime.datetime.now(tz=pytz.timezone("Europe/Vienna")).replace(tzinfo=None) - datetime.timedelta(days=150), - end_timestamp=datetime.datetime.now(tz=pytz.timezone("Europe/Vienna")).replace(tzinfo=None) - ): - state = zone.state - when = round_minute(zone.when, 10).astimezone(pytz.timezone("Europe/Vienna")) - if state == "work" and last_state != "work": - if work_periods and when - work_periods[-1]["end"] < datetime.timedelta(minutes=30): - work_periods[-1]["end"] = when - else: - work_periods.append({"start": when, "end": when}) - elif last_state == "work": - work_periods[-1]["end"] = when - last_state = state - print(f"zone changed to {last_state} at {when}") - # it has to have the same format as the old get_calendar_data - d = {} - for period in work_periods: - sd = period["start"].astimezone( - pytz.timezone("Europe/Vienna")) - sd_r = sd + datetime.timedelta(days=1) if sd > sd.replace(hour=18, minute=0, second=0) else sd - start_day = sd_r.strftime("%Y-%m-%d") - ed = period["end"].astimezone( - pytz.timezone("Europe/Vienna")) - ed_r = ed + datetime.timedelta(days=1) if ed > ed.replace(hour=18, minute=0, second=0) else ed - end_day = ed_r.strftime("%Y-%m-%d") - - if start_day not in d: - d[start_day] = [] - if end_day not in d: - d[end_day] = [] - thresh = datetime.datetime(year=period["end"].year, month=period["end"].month, day=period["end"].day, hour=18, - tzinfo=period["end"].tzinfo) - if start_day != end_day: - dur_first = thresh - period["start"] - if dur_first >= datetime.timedelta(hours=24): - dur_first -= datetime.timedelta(hours=24) - dur_second = period["end"] - thresh - if dur_second <= datetime.timedelta(hours=0): - dur_second += datetime.timedelta(hours=24) - total_dur = dur_first + dur_second - # split stats into two parts based on duration of each part - d[start_day].append( - {"relative_start": period["start"] - (thresh - datetime.timedelta(days=1)), "duration": dur_first}) - d[end_day].append( - {"relative_start": datetime.timedelta(), "duration": dur_second}) - else: - relative_start = period["start"] - (thresh - datetime.timedelta(days=1)) - if relative_start >= datetime.timedelta(hours=24): - relative_start -= datetime.timedelta(hours=24) - d[start_day].append( - {"relative_start": relative_start, "duration": period["end"] - period["start"]}) - return d - -def round_minute(date: datetime = None, round_to: int = 1): - """ - round datetime object to minutes - """ - if not date: - date = datetime.datetime.now() - minute = round(date.minute / round_to) * round_to - date = date.replace(minute=0, second=0, microsecond=0) - return date + datetime.timedelta(minutes=minute) - -async def setup(bot): - await bot.add_cog(Oura(bot)) diff --git a/rocketwatch/plugins/snapshot/snapshot.py b/rocketwatch/plugins/snapshot/snapshot.py index 0bf66eda..2e78c60c 100644 --- a/rocketwatch/plugins/snapshot/snapshot.py +++ b/rocketwatch/plugins/snapshot/snapshot.py @@ -1,47 +1,48 @@ -import math import logging +import math from dataclasses import dataclass -from typing import Optional, Literal from datetime import datetime, timedelta +from typing import Literal, Optional +import aiohttp import regex -import requests import termplotlib as tpl from discord import Interaction from discord.app_commands import command +from eth_typing import BlockNumber, ChecksumAddress +from graphql_query import Argument, Operation, Query +from pymongo import DESCENDING, DeleteOne, InsertOne, UpdateOne from web3.constants import ADDRESS_ZERO -from eth_typing import ChecksumAddress, BlockNumber -from graphql_query import Operation, Query, Argument -from pymongo import MongoClient, InsertOne, UpdateOne, DeleteOne, DESCENDING from rocketwatch import RocketWatch -from utils.cfg import cfg -from utils.embeds import Embed, el_explorer_url -from utils.image import Image, ImageCanvas, Color, FontVariant -from utils.readable import uptime -from utils.rocketpool import rp -from utils.event import EventPlugin, Event -from utils.visibility import is_hidden_weak from utils.block_time import ts_to_block +from utils.embeds import Embed, el_explorer_url +from utils.event import Event, EventPlugin +from utils.image import Color, FontVariant, Image, ImageCanvas +from utils.readable import pretty_time from utils.retry import retry +from utils.rocketpool import rp +from utils.visibility import is_hidden -log = logging.getLogger("snapshot") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.snapshot") class Snapshot(EventPlugin): def __init__(self, bot: RocketWatch): super().__init__(bot, timedelta(minutes=2)) - client = MongoClient(cfg["mongodb.uri"]).rocketwatch - self.proposal_db = client.snapshot_proposals - self.vote_db = client.snapshot_votes + self.proposal_db = bot.db.snapshot_proposals + self.vote_db = bot.db.snapshot_votes @staticmethod @retry(tries=3, delay=1) - def _query_api(query: Query) -> list[dict] | Optional[dict]: + async def _query_api(query: Query) -> list[dict] | dict | None: query_json = {"query": Operation(type="query", queries=[query]).render()} log.debug(f"Snapshot query: {query_json}") - response = requests.get("https://hub.snapshot.org/graphql", json=query_json).json() + async with ( + aiohttp.ClientSession() as session, + session.get("https://hub.snapshot.org/graphql", json=query_json) as resp, + ): + response = await resp.json() if "errors" in response: raise Exception(response["errors"]) return response["data"][query.name] @@ -75,7 +76,9 @@ def predict_render_height(self, with_title: bool = True) -> int: height = 0 if with_title: height = self._TITLE_SIZE + self._V_SPACE_LARGE - height += len(self.choices) * (self._predict_choice_height() + self._V_SPACE_MEDIUM) + height += len(self.choices) * ( + self._predict_choice_height() + self._V_SPACE_MEDIUM + ) height += self._V_SPACE_SMALL + self._HEADER_SIZE + self._V_SPACE_SMALL height += self._BAR_SIZE + self._V_SPACE_LARGE height += self._TEXT_SIZE @@ -85,13 +88,13 @@ def reached_quorum(self) -> bool: return sum(self.scores) >= self.quorum def render_to( - self, - canvas: ImageCanvas, - width: int, - x_offset: int = 0, - y_offset: int = 0, - *, - include_title: bool = True + self, + canvas: ImageCanvas, + width: int, + x_offset: int = 0, + y_offset: int = 0, + *, + include_title: bool = True, ) -> int: def safe_div(x, y): return (x / y) if y else 0 @@ -99,12 +102,14 @@ def safe_div(x, y): label_offset = self._BAR_SIZE / 2 label_font_variant = FontVariant.BOLD - def render_choice(_choice: str, _score: float, _x_offset: int, _y_offset: int) -> int: - color: Color = (128, 128, 128) # slate gray + def render_choice( + _choice: str, _score: float, _x_offset: int, _y_offset: int + ) -> int: + color: Color = (128, 128, 128) # slate gray choice_colors = { - "for": (4, 99, 7), # green - "against": (156, 0, 47), # red - "abstain": (114, 121, 138) + "for": (4, 99, 7), # green + "against": (156, 0, 47), # red + "abstain": (114, 121, 138), } for k, v in choice_colors.items(): # assign color based on keywords @@ -118,32 +123,40 @@ def render_choice(_choice: str, _score: float, _x_offset: int, _y_offset: int) - _choice, self._TEXT_SIZE, max_width=(width / 2), - anchor="lt" + anchor="lt", ) choice_height += self._TEXT_SIZE + self._V_SPACE_SMALL - divisor = max(self.scores) if len(self.scores) >= 5 else sum(self.scores) + divisor = ( + max(self.scores) if len(self.scores) >= 5 else sum(self.scores) + ) canvas.progress_bar( (_x_offset, _y_offset + choice_height), (width, self._BAR_SIZE), safe_div(_score, divisor), - fill_color=color + fill_color=color, ) canvas.dynamic_text( - (_x_offset + label_offset, _y_offset + choice_height + (self._BAR_SIZE / 2)), + ( + _x_offset + label_offset, + _y_offset + choice_height + (self._BAR_SIZE / 2), + ), f"{safe_div(_score, sum(self.scores)):.2%}", self._LABEL_SIZE, font_variant=label_font_variant, max_width=((width / 2) - label_offset), - anchor="lm" + anchor="lm", ) canvas.dynamic_text( - (_x_offset + width - label_offset, _y_offset + choice_height + (self._BAR_SIZE / 2)), + ( + _x_offset + width - label_offset, + _y_offset + choice_height + (self._BAR_SIZE / 2), + ), f"{_score:,.2f}", self._LABEL_SIZE, font_variant=label_font_variant, max_width=((width / 2) - label_offset), - anchor="rm" + anchor="rm", ) choice_height += self._BAR_SIZE return choice_height @@ -156,15 +169,17 @@ def render_choice(_choice: str, _score: float, _x_offset: int, _y_offset: int) - self.title, self._TITLE_SIZE, max_width=width, - anchor="mt" + anchor="mt", ) proposal_height += self._TITLE_SIZE + self._V_SPACE_LARGE # order (choice, score) pairs by score - choice_scores = list(zip(self.choices, self.scores)) + choice_scores = list(zip(self.choices, self.scores, strict=False)) choice_scores.sort(key=lambda x: x[1], reverse=True) for choice, score in choice_scores: - proposal_height += render_choice(choice, score, x_offset, y_offset + proposal_height) + proposal_height += render_choice( + choice, score, x_offset, y_offset + proposal_height + ) proposal_height += self._V_SPACE_MEDIUM proposal_height += self._V_SPACE_SMALL @@ -175,7 +190,7 @@ def render_choice(_choice: str, _score: float, _x_offset: int, _y_offset: int) - "Quorum", self._HEADER_SIZE, max_width=(width / 2), - anchor="lt" + anchor="lt", ) proposal_height += self._HEADER_SIZE + self._V_SPACE_SMALL quorum_perc: float = safe_div(sum(self.scores), self.quorum) @@ -187,25 +202,31 @@ def render_choice(_choice: str, _score: float, _x_offset: int, _y_offset: int) - (x_offset, y_offset + proposal_height), (width, self._BAR_SIZE), min(quorum_perc, 1), - fill_color=pb_color + fill_color=pb_color, ) canvas.dynamic_text( - (x_offset + label_offset, y_offset + proposal_height + (self._BAR_SIZE / 2)), + ( + x_offset + label_offset, + y_offset + proposal_height + (self._BAR_SIZE / 2), + ), f"{quorum_perc:.2%}", self._LABEL_SIZE, font_variant=label_font_variant, max_width=((width / 2) - label_offset), anchor="lm", - color=label_color + color=label_color, ) canvas.dynamic_text( - (x_offset + width - label_offset, y_offset + proposal_height + (self._BAR_SIZE / 2)), + ( + x_offset + width - label_offset, + y_offset + proposal_height + (self._BAR_SIZE / 2), + ), f"{sum(self.scores):,.0f} / {self.quorum:,.0f}", self._LABEL_SIZE, font_variant=label_font_variant, max_width=((width / 2) - label_offset), anchor="rm", - color=label_color + color=label_color, ) proposal_height += self._BAR_SIZE + self._V_SPACE_LARGE @@ -213,10 +234,10 @@ def render_choice(_choice: str, _score: float, _x_offset: int, _y_offset: int) - rem_time = self.end - datetime.now().timestamp() canvas.dynamic_text( (x_offset + (width / 2), y_offset + proposal_height), - f"{uptime(rem_time)} left" if (rem_time >= 0) else "Final Result", + f"{pretty_time(rem_time)} left" if (rem_time >= 0) else "Final Result", self._TEXT_SIZE, max_width=width, - anchor="mt" + anchor="mt", ) proposal_height += self._TEXT_SIZE return proposal_height @@ -235,20 +256,24 @@ def create_image(self, *, include_title: bool) -> Image: pad_left, pad_right = 20, 20 width = 800 height = self.predict_render_height(include_title) - canvas = ImageCanvas(width + pad_left + pad_right, height + pad_top + pad_bottom) - self.render_to(canvas, width, pad_left, pad_top, include_title=include_title) + canvas = ImageCanvas( + width + pad_left + pad_right, height + pad_top + pad_bottom + ) + self.render_to( + canvas, width, pad_left, pad_top, include_title=include_title + ) return canvas.image - def create_start_event(self) -> Event: + async def create_start_event(self) -> Event: embed = self.get_embed_template() embed.title = ":bulb: New Snapshot Proposal" return Event( embed=embed, topic="snapshot", - block_number=ts_to_block(self.start), + block_number=await ts_to_block(self.start), event_name="pdao_snapshot_vote_start", unique_id=f"snapshot_vote_start:{self.id}", - image=self.create_image(include_title=True) + image=self.create_image(include_title=True), ) def create_reached_quorum_event(self, block_number: BlockNumber) -> Event: @@ -260,12 +285,12 @@ def create_reached_quorum_event(self, block_number: BlockNumber) -> Event: block_number=block_number, event_name="pdao_snapshot_vote_quorum", unique_id=f"snapshot_vote_quorum:{self.id}", - image=self.create_image(include_title=True) + image=self.create_image(include_title=True), ) - def create_end_event(self) -> Event: - max_for, max_against = 0, 0 - for choice, score in zip(self.choices, self.scores): + async def create_end_event(self) -> Event: + max_for, max_against = 0.0, 0.0 + for choice, score in zip(self.choices, self.scores, strict=False): if "against" in choice.lower(): max_against = max(max_against, score) elif "abstain" not in choice.lower(): @@ -280,30 +305,30 @@ def create_end_event(self) -> Event: return Event( embed=embed, topic="snapshot", - block_number=ts_to_block(self.end), + block_number=await ts_to_block(self.end), event_name="pdao_snapshot_vote_end", unique_id=f"snapshot_vote_end:{self.id}", - image=self.create_image(include_title=True) + image=self.create_image(include_title=True), ) + type SingleChoice = int + type MultiChoice = list[int] + # weighted votes use strings as keys for some reason + type WeightedChoice = dict[str, int] + type Choice = SingleChoice | MultiChoice | WeightedChoice + @dataclass(frozen=True, slots=True) class Vote: - SingleChoice = int - MultiChoice = list[SingleChoice] - # weighted votes use strings as keys for some reason - WeightedChoice = dict[str, int] - Choice = (SingleChoice | MultiChoice | WeightedChoice) - - proposal: 'Snapshot.Proposal' + proposal: "Snapshot.Proposal" id: str voter: ChecksumAddress created: int vp: float - choice: Choice + choice: "Snapshot.Choice" reason: str - def pretty_print(self) -> Optional[str]: - match (raw_choice := self.choice): + def pretty_print(self) -> str | None: + match raw_choice := self.choice: case int(): return self._format_single_choice(raw_choice) case list(): @@ -314,11 +339,11 @@ def pretty_print(self) -> Optional[str]: log.error(f"Unknown vote type: {raw_choice}") return None - def _label_choice(self, raw_vote: SingleChoice) -> str: + def _label_choice(self, raw_vote: "Snapshot.SingleChoice") -> str: # vote choice represented as 1-based index return self.proposal.choices[raw_vote - 1] - def _format_single_choice(self, choice: SingleChoice): + def _format_single_choice(self, choice: "Snapshot.SingleChoice"): label = self._label_choice(choice) match label.lower(): case "for": @@ -329,30 +354,34 @@ def _format_single_choice(self, choice: SingleChoice): label = "⚪ Abstain" return f"`{label}`" - def _format_multiple_choice(self, choice: MultiChoice) -> str: + def _format_multiple_choice(self, choice: "Snapshot.MultiChoice") -> str: labels = [self._label_choice(c) for c in choice] if len(labels) == 1: return f"`{labels[0]}`" return "**" + "\n".join([f"- {c}" for c in labels]) + "**" - def _format_weighted_choice(self, choice: WeightedChoice) -> str: + def _format_weighted_choice(self, choice: "Snapshot.WeightedChoice") -> str: labels = {self._label_choice(int(c)): w for c, w in choice.items()} total_weight = sum(labels.values()) - choice_perc = [(c, round(100 * w / total_weight)) for c, w in labels.items()] + choice_perc = [ + (c, round(100 * w / total_weight)) for c, w in labels.items() + ] choice_perc.sort(key=lambda x: x[1], reverse=True) graph = tpl.figure() graph.barh( [x[1] for x in choice_perc], [x[0] for x in choice_perc], force_ascii=True, - max_width=15 + max_width=15, ) return "```" + graph.get_string().replace("]", "%]") + "```" - def create_event(self, prev_vote: Optional['Snapshot.Vote']) -> Optional[Event]: - node = rp.call("rocketSignerRegistry.signerToNode", self.voter) - signer = el_explorer_url(self.voter) - voter = signer if (node == ADDRESS_ZERO) else el_explorer_url(node) + async def create_event( + self, prev_vote: Optional["Snapshot.Vote"] + ) -> Event | None: + node = await rp.call("rocketSignerRegistry.signerToNode", self.voter) + signer = await el_explorer_url(self.voter) + voter = signer if (node == ADDRESS_ZERO) else await el_explorer_url(node) vote_fmt = self.pretty_print() if vote_fmt is None: @@ -366,14 +395,23 @@ def create_event(self, prev_vote: Optional['Snapshot.Vote']) -> Optional[Event]: embed.description = separator.join([f"{voter} voted", vote_fmt]) elif self.choice != prev_vote.choice: prev_vote_fmt = prev_vote.pretty_print() - parts = [f"{voter} changed their vote from", prev_vote_fmt, "to", vote_fmt] + if prev_vote_fmt is None: + return None + parts = [ + f"{voter} changed their vote from", + prev_vote_fmt, + "to", + vote_fmt, + ] separator = " " if (len(vote_fmt) + len(prev_vote_fmt) <= 20) else "\n" embed.description = separator.join(parts) elif self.reason != prev_vote.reason: embed.description = ( - f"{voter} " - "changed the reason for their vote" if prev_vote.reason else "added context to their vote" - f" ({vote_fmt})" if (len(vote_fmt) <= 20) else f":\n{vote_fmt}" + f"{voter} changed the reason for their vote" + if prev_vote.reason + else f"added context to their vote ({vote_fmt})" + if (len(vote_fmt) <= 20) + else f":\n{vote_fmt}" ) else: log.debug("Same vote as before, skipping event") @@ -385,7 +423,7 @@ def create_event(self, prev_vote: Optional['Snapshot.Vote']) -> Optional[Event]: if len(embed.description) + len(reason) > max_length: suffix = "..." overage = len(embed.description) + len(reason) - max_length - reason = reason[:-(overage + len(suffix))] + suffix + reason = reason[: -(overage + len(suffix))] + suffix embed.description += f" ```{reason}```" @@ -396,39 +434,35 @@ def create_event(self, prev_vote: Optional['Snapshot.Vote']) -> Optional[Event]: if self.vp >= 250: conditional_args = { "event_name": "pdao_snapshot_vote", - "image": self.proposal.create_image(include_title=False) + "image": self.proposal.create_image(include_title=False), } else: conditional_args = { "event_name": "snapshot_vote", - "thumbnail": self.proposal.create_image(include_title=False) + "thumbnail": self.proposal.create_image(include_title=False), } return Event( embed=embed, topic="snapshot", - block_number=ts_to_block(self.created), + block_number=await ts_to_block(self.created), unique_id=f"snapshot_vote:{self.proposal.id}:{self.voter}:{self.created}", - **conditional_args + **conditional_args, ) @staticmethod - def fetch_proposal(proposal_id: str) -> Optional[Proposal]: + async def fetch_proposal(proposal_id: str) -> Proposal | None: query = Query( name="proposal", - arguments=[Argument(name="id", value=f"\"{proposal_id}\"")], - fields=["id", "title", "choices", "start", "end", "scores", "quorum"] + arguments=[Argument(name="id", value=f'"{proposal_id}"')], + fields=["id", "title", "choices", "start", "end", "scores", "quorum"], ) - response: Optional[dict] = Snapshot._query_api(query) + response: dict | None = await Snapshot._query_api(query) return Snapshot.Proposal(**response) if response else None @staticmethod - def fetch_proposals( - state: Proposal.State, - *, - reverse: bool = False, - limit: int = 25, - skip: int = 0 + async def fetch_proposals( + state: Proposal.State, *, reverse: bool = False, limit: int = 25, skip: int = 0 ) -> list[Proposal]: query = Query( name="proposals", @@ -438,26 +472,26 @@ def fetch_proposals( Argument( name="where", value=[ - Argument(name="space_in", value=["\"rocketpool-dao.eth\""]), - Argument(name="state", value=f"\"{state}\"") - ] + Argument(name="space_in", value=['"rocketpool-dao.eth"']), + Argument(name="state", value=f'"{state}"'), + ], ), - Argument(name="orderBy", value="\"created\""), - Argument(name="orderDirection", value="desc" if reverse else "asc") + Argument(name="orderBy", value='"created"'), + Argument(name="orderDirection", value="desc" if reverse else "asc"), ], - fields=["id", "title", "choices", "start", "end", "scores", "quorum"] + fields=["id", "title", "choices", "start", "end", "scores", "quorum"], ) - response: list[dict] = Snapshot._query_api(query) + response: list[dict] = await Snapshot._query_api(query) return [Snapshot.Proposal(**d) for d in response] @staticmethod - def fetch_votes( - proposal: Proposal, - *, - created_after: int = 0, - reverse: bool = False, - limit: int = 100, - skip: int = 0 + async def fetch_votes( + proposal: Proposal, + *, + created_after: int = 0, + reverse: bool = False, + limit: int = 100, + skip: int = 0, ) -> list[Vote]: query = Query( name="votes", @@ -467,19 +501,19 @@ def fetch_votes( Argument( name="where", value=[ - Argument(name="proposal", value=f"\"{proposal.id}\""), - Argument(name="created_gt", value=created_after) - ] + Argument(name="proposal", value=f'"{proposal.id}"'), + Argument(name="created_gt", value=created_after), + ], ), - Argument(name="orderBy", value="\"created\""), - Argument(name="orderDirection", value="desc" if reverse else "asc") + Argument(name="orderBy", value='"created"'), + Argument(name="orderDirection", value="desc" if reverse else "asc"), ], - fields=["id", "voter", "created", "vp", "choice", "reason"] + fields=["id", "voter", "created", "vp", "choice", "reason"], ) - response: list[dict] = Snapshot._query_api(query) + response: list[dict] = await Snapshot._query_api(query) return [Snapshot.Vote(proposal=proposal, **d) for d in response] - def _get_new_events(self) -> list[Event]: + async def _get_new_events(self) -> list[Event]: now = datetime.now() events: list[Event] = [] @@ -487,58 +521,70 @@ def _get_new_events(self) -> list[Event]: vote_db_changes: list[InsertOne] = [] known_active_proposals: dict[str, dict] = {} - for stored_proposal in self.proposal_db.find(): + async for stored_proposal in self.proposal_db.find(): if stored_proposal["end"] >= now.timestamp(): known_active_proposals[stored_proposal["_id"]] = stored_proposal else: # stored proposal ended, emit event and delete from DB log.info(f"Found expired proposal: {stored_proposal}") # recover full proposal - if proposal := self.fetch_proposal(stored_proposal["_id"]): - event = proposal.create_end_event() + if proposal := await self.fetch_proposal(stored_proposal["_id"]): + event = await proposal.create_end_event() proposal_db_changes.append(DeleteOne(stored_proposal)) events.append(event) - active_proposals = self.fetch_proposals("active") + active_proposals = await self.fetch_proposals("active") for proposal in active_proposals: log.debug(f"Processing proposal {proposal}") if proposal.id not in known_active_proposals: # not aware of this proposal yet, emit event and insert into DB log.info(f"Found new proposal: {proposal}") - event = proposal.create_start_event() + event = await proposal.create_start_event() proposal_dict = { - "_id" : proposal.id, - "start" : proposal.start, - "end" : proposal.end, - "quorum": proposal.reached_quorum() + "_id": proposal.id, + "start": proposal.start, + "end": proposal.end, + "quorum": proposal.reached_quorum(), } proposal_db_changes.append(InsertOne(proposal_dict)) known_active_proposals[proposal.id] = proposal_dict events.append(event) - elif proposal.reached_quorum() and (not known_active_proposals[proposal.id]["quorum"]): + elif proposal.reached_quorum() and ( + not known_active_proposals[proposal.id]["quorum"] + ): log.info(f"Proposal {proposal} has reached quorum") event = proposal.create_reached_quorum_event(self._pending_block) - proposal_db_changes.append(UpdateOne( - {"_id": proposal.id}, - {"$set": {"quorum": True}} - )) + proposal_db_changes.append( + UpdateOne({"_id": proposal.id}, {"$set": {"quorum": True}}) + ) events.append(event) try: - last_vote_ts = self.vote_db.find( - {"proposal_id": proposal.id} - ).sort({"created": DESCENDING}).limit(1)[0]["created"] + last_vote_entry = ( + await self.vote_db.find({"proposal_id": proposal.id}) + .sort({"created": DESCENDING}) + .limit(1) + .to_list() + ) + last_vote_ts = last_vote_entry[0]["created"] except IndexError: last_vote_ts = 0 - current_votes: list[Snapshot.Vote] = self.fetch_votes(proposal, created_after=last_vote_ts) + current_votes: list[Snapshot.Vote] = await self.fetch_votes( + proposal, created_after=last_vote_ts + ) for vote in current_votes: log.debug(f"Processing vote {vote}") try: - stored_vote = self.vote_db.find( - {"proposal_id": proposal.id, "voter": vote.voter} - ).sort({"created": DESCENDING}).limit(1)[0] + stored_vote = ( + await self.vote_db.find( + {"proposal_id": proposal.id, "voter": vote.voter} + ) + .sort({"created": DESCENDING}) + .limit(1) + .to_list() + )[0] prev_vote = Snapshot.Vote( id=stored_vote["_id"], proposal=proposal, @@ -546,51 +592,55 @@ def _get_new_events(self) -> list[Event]: created=stored_vote["created"], vp=stored_vote["vp"], choice=stored_vote["choice"], - reason=stored_vote["reason"] + reason=stored_vote["reason"], ) except IndexError: prev_vote = None - event = vote.create_event(prev_vote) + event = await vote.create_event(prev_vote) if event is None: continue events.append(event) - db_update = InsertOne({ - "_id" : vote.id, - "proposal_id": vote.proposal.id, - "voter" : vote.voter, - "created" : vote.created, - "vp" : vote.vp, - "choice" : vote.choice, - "reason" : vote.reason, - }) + db_update = InsertOne( + { + "_id": vote.id, + "proposal_id": vote.proposal.id, + "voter": vote.voter, + "created": vote.created, + "vp": vote.vp, + "choice": vote.choice, + "reason": vote.reason, + } + ) vote_db_changes.append(db_update) if proposal_db_changes: - self.proposal_db.bulk_write(proposal_db_changes) + await self.proposal_db.bulk_write(proposal_db_changes) if vote_db_changes: - self.vote_db.bulk_write(vote_db_changes) + await self.vote_db.bulk_write(vote_db_changes) return events @command() async def snapshot_votes(self, interaction: Interaction): """Show currently active Snapshot proposals""" - await interaction.response.defer(ephemeral=is_hidden_weak(interaction)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) embed = Embed(title="Snapshot Proposals") - embed.set_author(name="🔗 Data from snapshot.org", url="https://vote.rocketpool.net") + embed.set_author( + name="🔗 Data from snapshot.org", url="https://vote.rocketpool.net" + ) - proposals = self.fetch_proposals("active", reverse=True)[::-1] + proposals = (await self.fetch_proposals("active", reverse=True))[::-1] if not proposals: embed.description = "No active proposals." return await interaction.followup.send(embed=embed) num_proposals = len(proposals) - num_cols = min(int(math.ceil(math.sqrt(num_proposals))), 4) - num_rows = int(math.ceil(num_proposals / num_cols)) + num_cols = min(math.ceil(math.sqrt(num_proposals)), 4) + num_rows = math.ceil(num_proposals / num_cols) v_spacing = 120 h_spacing = 80 @@ -605,7 +655,7 @@ async def snapshot_votes(self, interaction: Interaction): total_height = v_spacing * (num_rows - 1) proposal_grid: list[list[Snapshot.Proposal]] = [] for row_idx in range(num_rows): - row = proposals[row_idx*num_cols:(row_idx+1)*num_cols] + row = proposals[row_idx * num_cols : (row_idx + 1) * num_cols] proposal_grid.append(row) # row height is equal to height of its tallest proposal total_height += max(p.predict_render_height() for p in row) @@ -613,9 +663,11 @@ async def snapshot_votes(self, interaction: Interaction): # make sure proportions don't become too skewed if total_width < total_height: proposal_width = (total_height - h_spacing * (num_cols - 1)) // num_cols - total_width = (proposal_width * num_cols) + h_spacing * (num_cols - 1) + pad_left + pad_right + total_width = (proposal_width * num_cols) + h_spacing * (num_cols - 1) - canvas = ImageCanvas(total_width + pad_top + pad_bottom, total_height + pad_left + pad_right) + canvas = ImageCanvas( + total_width + pad_left + pad_right, total_height + pad_top + pad_bottom + ) # draw proposals in num_rows x num_cols grid y_offset = pad_top diff --git a/rocketwatch/plugins/support_utils/support_utils.py b/rocketwatch/plugins/support_utils/support_utils.py index 9bcacc73..7843f08c 100644 --- a/rocketwatch/plugins/support_utils/support_utils.py +++ b/rocketwatch/plugins/support_utils/support_utils.py @@ -1,63 +1,68 @@ -import io import logging -from datetime import datetime, timezone +from datetime import UTC, datetime +from operator import itemgetter from bson import CodecOptions -from discord import app_commands, ui, Interaction, TextStyle, ButtonStyle, File, User -from discord.app_commands import Group, Choice, choices +from discord import ButtonStyle, Interaction, Member, TextStyle, User, app_commands, ui +from discord.app_commands import Choice, Group, choices from discord.ext.commands import Cog, GroupCog -from motor.motor_asyncio import AsyncIOMotorClient from rocketwatch import RocketWatch -from utils.cfg import cfg +from utils.config import cfg from utils.embeds import Embed +from utils.file import TextFile -log = logging.getLogger("support_utils") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.support_utils") async def generate_template_embed(db, template_name: str): - # get the boiler message from the database - template = await db.support_bot.find_one({'_id': template_name}) - if not template: + template = await db.support_bot.find_one({"_id": template_name}) + if not template: return None # get the last log entry from the db - dumps_col = db.support_bot_dumps.with_options(codec_options=CodecOptions(tz_aware=True)) - last_edit = await dumps_col.find_one( - {"template": template_name}, - sort=[("ts", -1)] + dumps_col = db.support_bot_dumps.with_options( + codec_options=CodecOptions(tz_aware=True) ) - - e = Embed(title=template['title'], description=template['description']) + last_edit = await dumps_col.find_one({"template": template_name}, sort=[("ts", -1)]) + embed = Embed(title=template["title"]) + embed.description = template["description"] or "" if last_edit and template_name != "announcement": - e.description += f"\n\n*Last Edited by <@{last_edit['author']['id']}> *" - return e + embed.description += f"\n\n*Last Edited by <@{last_edit['author']['id']}> *" + return embed # Define a simple View that gives us a counter button class AdminView(ui.View): - def __init__(self, db: AsyncIOMotorClient, template_name: str): + def __init__(self, db, template_name: str): super().__init__() self.db = db self.template_name = template_name - @ui.button(label='Edit', style=ButtonStyle.blurple) + @ui.button(label="Edit", style=ButtonStyle.blurple) async def edit(self, interaction: Interaction, button: ui.Button): - template = await self.db.support_bot.find_one({'_id': self.template_name}) + template = await self.db.support_bot.find_one({"_id": self.template_name}) # Make sure to update the message with our update - await interaction.response.send_modal(AdminModal(template["title"], template["description"], self.db, self.template_name)) + await interaction.response.send_modal( + AdminModal( + template["title"], template["description"], self.db, self.template_name + ) + ) class DeletableView(ui.View): - def __init__(self, user: User): + def __init__(self, user: User | Member): super().__init__(timeout=None) self.user = user - + @ui.button(emoji="<:delete:1364953621191721002>", style=ButtonStyle.gray) async def delete(self, interaction: Interaction, button: ui.Button): - if (interaction.user == self.user) or has_perms(interaction): + if ( + (interaction.user == self.user) or has_perms(interaction) + ) and interaction.message: await interaction.message.delete() - log.warning(f"Support template deleted by {interaction.user} in {interaction.channel}") + log.warning( + f"Support template message deleted by {interaction.user} in {interaction.channel}" + ) class AdminModal(ui.Modal, title="Change Template Message"): @@ -67,26 +72,27 @@ def __init__(self, old_title, old_description, db, template_name): self.old_title = old_title self.old_description = old_description self.template_name = template_name - self.title_field = ui.TextInput( - label="Title", - placeholder="Enter a title", - default=old_title + self.title_field: ui.TextInput[AdminModal] = ui.TextInput( + label="Title", placeholder="Enter a title", default=old_title ) - self.description_field = ui.TextInput( + self.description_field: ui.TextInput[AdminModal] = ui.TextInput( label="Description", placeholder="Enter a description", default=old_description, style=TextStyle.paragraph, - max_length=4000 + max_length=4000, ) self.add_item(self.title_field) self.add_item(self.description_field) async def on_submit(self, interaction: Interaction) -> None: # get the data from the db - template = await self.db.support_bot.find_one({'_id': self.template_name}) + template = await self.db.support_bot.find_one({"_id": self.template_name}) # verify that no changes were made while we were editing - if template["title"] != self.old_title or template["description"] != self.old_description: + if ( + template["title"] != self.old_title + or template["description"] != self.old_description + ): # dump the description into a memory file await interaction.response.edit_message( embed=Embed( @@ -94,50 +100,70 @@ async def on_submit(self, interaction: Interaction) -> None: "Someone made changes while you were editing. Please try again.\n" "Your pending changes have been attached to this message." ), - view=None + view=None, ) ) a = await interaction.original_response() - file = File(io.StringIO(self.description_field.value), f"{self.title_field.value}.txt") + file = TextFile( + self.description_field.value, f"{self.title_field.value}.txt" + ) await a.add_files(file) return try: await self.db.support_bot_dumps.insert_one( { - "ts" : datetime.now(timezone.utc), + "ts": datetime.now(UTC), "template": self.template_name, - "prev" : template, - "new" : { - "title" : self.title_field.value, - "description": self.description_field.value + "prev": template, + "new": { + "title": self.title_field.value, + "description": self.description_field.value, + }, + "author": { + "id": interaction.user.id, + "name": interaction.user.name, }, - "author" : { - "id" : interaction.user.id, - "name": interaction.user.name - } - }) + } + ) except Exception as e: log.error(e) await self.db.support_bot.update_one( {"_id": self.template_name}, - {"$set": {"title": self.title_field.value, "description": self.description_field.value}} + { + "$set": { + "title": self.title_field.value, + "description": self.description_field.value, + } + }, ) content = ( - f"This is a preview of the '{self.template_name}' template.\n" - f"You can change it using the 'Edit' button." + f"This is a preview of the `{self.template_name}` template.\n" + f"You can change it using the `Edit` button." ) embed = await generate_template_embed(self.db, self.template_name) - await interaction.response.edit_message(content=content, embed=embed, view=AdminView(self.db, self.template_name)) + await interaction.response.edit_message( + content=content, embed=embed, view=AdminView(self.db, self.template_name) + ) -def has_perms(interaction: Interaction): - return any([ - any(r.id in cfg["rocketpool.support.role_ids"] for r in interaction.user.roles), - cfg["discord.owner.user_id"] == interaction.user.id, - interaction.user.guild_permissions.moderate_members and interaction.guild.id == cfg["rocketpool.support.server_id"] - ]) +def has_perms(interaction: Interaction) -> bool: + user = interaction.user + if user.id in cfg.rocketpool.support.user_ids: + return True + if cfg.discord.owner.user_id == user.id: + return True + if isinstance(user, Member): + if any(r.id in cfg.rocketpool.support.role_ids for r in user.roles): + return True + if ( + user.guild_permissions.moderate_members + and interaction.guild + and interaction.guild.id == cfg.rocketpool.support.server_id + ): + return True + return False async def _use(db, interaction: Interaction, name: str, mention: User | None): @@ -147,212 +173,197 @@ async def _use(db, interaction: Interaction, name: str, mention: User | None): await interaction.response.send_message( embed=Embed( title="Error", - description=f"A template with the name '{name}' does not exist." - ), - ephemeral=True - ) - return - if name == "boiler": - await interaction.response.send_message( - embed=Embed( - title="Error", - description=f"The template '{name}' cannot be used." + description=f"A template with the name '{name}' does not exist.", ), - ephemeral=True + ephemeral=True, ) return + # respond with the template embed if e := (await generate_template_embed(db, name)): await interaction.response.send_message( content=mention.mention if mention else "", embed=e, - view=DeletableView(interaction.user) + view=DeletableView(interaction.user), ) else: await interaction.response.send_message( embed=Embed( title="Error", - description="An error occurred while generating the template embed." + description="An error occurred while generating the template embed.", ), - ephemeral=True + ephemeral=True, ) class SupportGlobal(Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch @app_commands.command(name="use") - async def _use_1(self, interaction: Interaction, name: str, mention: User | None): - await _use(self.db, interaction, name, mention) + async def _use(self, interaction: Interaction, name: str, mention: User | None): + await _use(self.bot.db, interaction, name, mention) - @app_commands.command(name="template") - async def _use_2(self, interaction: Interaction, name: str, mention: User | None): - await _use(self.db, interaction, name, mention) - - @_use_1.autocomplete("name") - @_use_2.autocomplete("name") + @_use.autocomplete("name") async def match_template(self, interaction: Interaction, current: str): return [ - Choice( - name=c["_id"], - value=c["_id"] - ) for c in await self.db.support_bot.find( - { - "_id": { - "$regex": current, - "$options": "i", - "$ne" : "boiler" if interaction.command.name != "edit" else None - } - } + Choice(name=c["_id"], value=c["_id"]) + for c in await self.bot.db.support_bot.find( + {"_id": {"$regex": current, "$options": "i"}} ).to_list(25) ] class SupportUtils(GroupCog, name="support"): subgroup = Group( - name='template', - description='various templates used by active support members', - guild_ids=[cfg["rocketpool.support.server_id"]] + name="template", + description="various templates used by active support members", + guild_ids=[cfg.rocketpool.support.server_id], ) def __init__(self, bot: RocketWatch): self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).rocketwatch - - @Cog.listener() - async def on_ready(self): - # insert the boiler message into the database, if it doesn't already exist - await self.db.support_bot.update_one( - {'_id': 'boiler'}, - {'$setOnInsert': { - 'title' : 'Support Message', - 'description': 'This is a support message.' - }}, - upsert=True - ) @subgroup.command() async def add(self, interaction: Interaction, name: str): if not has_perms(interaction): await interaction.response.send_message( - embed=Embed(title="Error", description="You do not have permission to use this command."), ephemeral=True) + embed=Embed( + title="Error", + description="You do not have permission to use this command.", + ), + ephemeral=True, + ) return await interaction.response.defer(ephemeral=True) # check if the template already exists in the db - if await self.db.support_bot.find_one({"_id": name}): + if await self.bot.db.support_bot.find_one({"_id": name}): await interaction.edit_original_response( embed=Embed( title="Error", - description=f"A template with the name '{name}' already exists." + description=f"A template with the name '{name}' already exists.", ), ) return # create the template in the db - await self.db.support_bot.insert_one( - {"_id": name, "title": "Insert Title here", "description": "Insert Description here"} + await self.bot.db.support_bot.insert_one( + { + "_id": name, + "title": "Insert Title here", + "description": "Insert Description here", + } ) content = ( - f"This is a preview of the '{name}' template.\n" - f"You can change it using the 'Edit' button." + f"This is a preview of the `{name}` template.\n" + f"You can change it using the `Edit` button." + ) + embed = await generate_template_embed(self.bot.db, name) + await interaction.edit_original_response( + content=content, embed=embed, view=AdminView(self.bot.db, name) ) - embed = await generate_template_embed(self.db, name) - await interaction.edit_original_response(content=content, embed=embed, view=AdminView(self.db, name)) @subgroup.command() async def edit(self, interaction: Interaction, name: str): if not has_perms(interaction): await interaction.response.send_message( - embed=Embed(title="Error", description="You do not have permission to use this command."), ephemeral=True) + embed=Embed( + title="Error", + description="You do not have permission to use this command.", + ), + ephemeral=True, + ) return await interaction.response.defer(ephemeral=True) # check if the template exists in the db - template = await self.db.support_bot.find_one({"_id": name}) + template = await self.bot.db.support_bot.find_one({"_id": name}) if not template: await interaction.edit_original_response( embed=Embed( title="Error", - description=f"A template with the name '{name}' does not exist." + description=f"A template with the name '{name}' does not exist.", ), ) return - + content = ( - f"This is a preview of the '{name}' template.\n" - f"You can change it using the 'Edit' button." + f"This is a preview of the `{name}` template.\n" + f"You can change it using the `Edit` button." + ) + embed = await generate_template_embed(self.bot.db, name) + await interaction.edit_original_response( + content=content, embed=embed, view=AdminView(self.bot.db, name) ) - embed = await generate_template_embed(self.db, name) - await interaction.edit_original_response(content=content, embed=embed, view=AdminView(self.db, name)) @subgroup.command() async def remove(self, interaction: Interaction, name: str): if not has_perms(interaction): await interaction.response.send_message( - embed=Embed(title="Error", description="You do not have permission to use this command."), ephemeral=True) - return - if name == "boiler": - await interaction.edit_original_response( embed=Embed( title="Error", - description=f"The template '{name}' cannot be removed." + description="You do not have permission to use this command.", ), + ephemeral=True, ) return await interaction.response.defer(ephemeral=True) # check if the template exists in the db - template = await self.db.support_bot.find_one({"_id": name}) + template = await self.bot.db.support_bot.find_one({"_id": name}) if not template: await interaction.edit_original_response( embed=Embed( title="Error", - description=f"A template with the name '{name}' does not exist." + description=f"A template with the name '{name}' does not exist.", ), ) return # remove the template from the db - await self.db.support_bot.delete_one({"_id": name}) + await self.bot.db.support_bot.delete_one({"_id": name}) await interaction.edit_original_response( - embed=Embed( - title="Success", - description=f"Template '{name}' removed." - ), + embed=Embed(title="Success", description=f"Template '{name}' removed."), ) @subgroup.command() @choices( order_by=[ Choice(name="Name", value="_id"), - Choice(name="Last Edited Date", value="last_edited_date") + Choice(name="Last Edited Date", value="last_edited_date"), ] ) - async def list(self, interaction: Interaction, order_by: Choice[str] = "_id"): + async def list(self, interaction: Interaction, order_by: str = "_id"): await interaction.response.defer(ephemeral=True) # get all templates and their last edited date using the support_bot_dumps collection - templates = await self.db.support_bot.aggregate([ - { - "$lookup": { - "from": "support_bot_dumps", - "localField": "_id", - "foreignField": "template", - "as": "dump" - } - }, - { - "$project": { - "_id": 1, - "last_edited_date": {"$arrayElemAt": ["$dump.ts", 0]} - } - } - ]).to_list(None) + templates = await ( + await self.bot.db.support_bot.aggregate( + [ + { + "$lookup": { + "from": "support_bot_dumps", + "localField": "_id", + "foreignField": "template", + "as": "dump", + } + }, + { + "$project": { + "_id": 1, + "last_edited_date": {"$arrayElemAt": ["$dump.ts", 0]}, + } + }, + ] + ) + ).to_list() # sort the templates by the specified order - if isinstance(order_by, Choice): - order_by = order_by.value - templates.sort(key=lambda x: x[order_by]) + templates.sort(key=itemgetter(order_by)) # create the embed embed = Embed(title="Templates") - embed.description = "".join(f"\n`{template['_id']}` - " for template in templates) + "" + embed.description = ( + "".join( + f"\n`{template['_id']}` - " + for template in templates + ) + + "" + ) # split the embed into multiple embeds if it is too long embeds = [embed] while len(embeds[-1]) > 6000: @@ -362,27 +373,18 @@ async def list(self, interaction: Interaction, order_by: Choice[str] = "_id"): embed.description = embed.description[:6000] await interaction.edit_original_response(embeds=embeds) - @subgroup.command() async def use(self, interaction: Interaction, name: str, mention: User | None): - await _use(self.db, interaction, name, mention) + await _use(self.bot.db, interaction, name, mention) @edit.autocomplete("name") @remove.autocomplete("name") @use.autocomplete("name") async def match_template(self, interaction: Interaction, current: str): return [ - Choice( - name=c["_id"], - value=c["_id"] - ) for c in await self.db.support_bot.find( - { - "_id": { - "$regex": current, - "$options": "i", - "$ne" : "boiler" if interaction.command.name != "edit" else None - } - } + Choice(name=c["_id"], value=c["_id"]) + for c in await self.bot.db.support_bot.find( + {"_id": {"$regex": current, "$options": "i"}} ).to_list(25) ] diff --git a/rocketwatch/plugins/transactions/functions.json b/rocketwatch/plugins/transactions/functions.json index eb8a2cc7..6034fb6c 100644 --- a/rocketwatch/plugins/transactions/functions.json +++ b/rocketwatch/plugins/transactions/functions.json @@ -13,7 +13,6 @@ "execute": "odao_proposal_execute", "proposalSettingUint": "odao_setting", "proposalSettingBool": "odao_setting", - "proposalUpgrade": "odao_upgrade", "proposalInvite": "odao_member_invite" }, "rocketDAOSecurityProposals": { @@ -52,11 +51,6 @@ "deposit": "minipool_failed_deposit", "depositWithCredit": "minipool_failed_deposit" }, - "rocketDepositPoolQueue": { - "clearHalfQueue": "deposit_pool_queue_clear_partial", - "clearQueue": "deposit_pool_queue_clear_full", - "clearQueueUpTo": "deposit_pool_queue_clear_partial" - }, "rocketUpgradeOneDotOne": { "execute": "redstone_upgrade_triggered" }, @@ -68,5 +62,8 @@ }, "rocketUpgradeOneDotThreeDotOne": { "execute": "houston_hotfix_upgrade_triggered" + }, + "rocketUpgradeOneDotFour": { + "execute": "saturn_one_upgrade_triggered" } } diff --git a/rocketwatch/plugins/transactions/transactions.py b/rocketwatch/plugins/transactions/transactions.py index aa32a456..c62de9e7 100644 --- a/rocketwatch/plugins/transactions/transactions.py +++ b/rocketwatch/plugins/transactions/transactions.py @@ -3,36 +3,36 @@ import warnings import web3.exceptions -import humanize -from datetime import timedelta from discord import Interaction from discord.app_commands import command, guilds from discord.ext.commands import is_owner -from eth_typing import ChecksumAddress, BlockNumber, BlockIdentifier +from eth_typing import BlockIdentifier, BlockNumber, ChecksumAddress from web3.datastructures import MutableAttributeDict as aDict from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg +from utils.config import cfg from utils.dao import DefaultDAO, ProtocolDAO -from utils.embeds import assemble, prepare_args, el_explorer_url, Embed -from utils.event import EventPlugin, Event +from utils.embeds import Embed, assemble, el_explorer_url, prepare_args +from utils.event import Event, EventPlugin from utils.rocketpool import rp from utils.shared_w3 import w3 -log = logging.getLogger("transactions") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.transactions") class Transactions(EventPlugin): def __init__(self, bot: RocketWatch): super().__init__(bot) - contract_addresses, function_map = self._parse_transaction_config() - self.addresses = contract_addresses - self.function_map = function_map + self.addresses = None + self.function_map = None + + async def _ensure_config(self): + if self.addresses is None: + self.addresses, self.function_map = await self._parse_transaction_config() @staticmethod - def _parse_transaction_config() -> tuple[list[ChecksumAddress], dict]: + async def _parse_transaction_config() -> tuple[list[ChecksumAddress], dict]: addresses: list[ChecksumAddress] = [] function_map = {} @@ -41,7 +41,7 @@ def _parse_transaction_config() -> tuple[list[ChecksumAddress], dict]: for contract_name, mapping in tx_config.items(): try: - address = rp.get_address_by_name(contract_name) + address = await rp.get_address_by_name(contract_name) addresses.append(address) function_map[contract_name] = mapping except Exception: @@ -50,67 +50,80 @@ def _parse_transaction_config() -> tuple[list[ChecksumAddress], dict]: return addresses, function_map @command() - @guilds(cfg["discord.owner.server_id"]) + @guilds(cfg.discord.owner.server_id) @is_owner() async def trigger_tx( - self, - interaction: Interaction, - contract: str, - function: str, - json_args: str = "{}", - block_number: int = 0 + self, + interaction: Interaction, + contract: str, + function: str, + json_args: str = "{}", + block_number: int = 0, ) -> None: await interaction.response.defer() try: - event_obj = aDict({ - "hash": aDict({"hex": lambda: '0x0000000000000000000000000000000000000000'}), - "blockNumber": block_number, - "args": json.loads(json_args) | {"function_name": function} - }) + event_obj = aDict( + { + "hash": aDict( + {"hex": lambda: "0x0000000000000000000000000000000000000000"} + ), + "blockNumber": block_number, + "args": json.loads(json_args) | {"function_name": function}, + } + ) except json.JSONDecodeError: await interaction.followup.send(content="Invalid JSON args!") return event_name = self.function_map[contract][function] - if embeds := self.create_embeds(event_name, event_obj): + if embeds := await self.create_embeds(event_name, event_obj): await interaction.followup.send(embeds=embeds) else: await interaction.followup.send(content="No events triggered.") @command() - @guilds(cfg["discord.owner.server_id"]) + @guilds(cfg.discord.owner.server_id) @is_owner() async def replay_tx(self, interaction: Interaction, tx_hash: str): await interaction.response.defer() - tnx = w3.eth.get_transaction(tx_hash) - block = w3.eth.get_block(tnx.blockHash) + await self._ensure_config() + tnx = await w3.eth.get_transaction(tx_hash) + block = await w3.eth.get_block(tnx.blockHash) - responses: list[Event] = self.process_transaction(block, tnx, tnx.to, tnx.input) + responses: list[Event] = await self.process_transaction( + block, tnx, tnx.to, tnx.input + ) if responses: - await interaction.followup.send(embeds=[response.embed for response in responses]) + await interaction.followup.send( + embeds=[response.embed for response in responses] + ) else: await interaction.followup.send(content="No events found.") - def _get_new_events(self) -> list[Event]: + async def _get_new_events(self) -> list[Event]: + await self._ensure_config() old_addresses = self.addresses try: from_block = self.last_served_block + 1 - self.lookback_distance - return self.get_past_events(from_block, self._pending_block) + return await self.get_past_events(from_block, self._pending_block) except Exception as err: # rollback in case of contract upgrade self.addresses = old_addresses raise err - def get_past_events(self, from_block: BlockNumber, to_block: BlockNumber) -> list[Event]: + async def get_past_events( + self, from_block: BlockNumber, to_block: BlockNumber + ) -> list[Event]: + await self._ensure_config() events = [] for block in range(from_block, to_block): - events.extend(self.get_events_for_block(block)) + events.extend(await self.get_events_for_block(block)) return events - def get_events_for_block(self, block_number: BlockIdentifier) -> list[Event]: + async def get_events_for_block(self, block_number: BlockIdentifier) -> list[Event]: log.debug(f"Checking block {block_number}") try: - block = w3.eth.get_block(block_number, full_transactions=True) + block = await w3.eth.get_block(block_number, full_transactions=True) except web3.exceptions.BlockNotFound: log.error(f"Skipping block {block_number} as it can't be found") return [] @@ -118,17 +131,18 @@ def get_events_for_block(self, block_number: BlockIdentifier) -> list[Event]: events = [] for tnx in block.transactions: if "to" in tnx: - events.extend(self.process_transaction(block, tnx, tnx.to, tnx.input)) + events.extend( + await self.process_transaction(block, tnx, tnx.to, tnx.input) + ) else: - log.debug(( + log.debug( f"Skipping transaction {tnx.hash.hex()} as it has no `to` parameter. " - f"Possible contract creation.") + f"Possible contract creation." ) return events - @staticmethod - def create_embeds(event_name: str, event: aDict) -> list[Embed]: + async def create_embeds(self, event_name: str, event: aDict) -> list[Embed]: # prepare args args = aDict(event.args) @@ -136,53 +150,65 @@ def create_embeds(event_name: str, event: aDict) -> list[Embed]: args.event_name = event_name # add transaction hash and block number to args - args.transactionHash = event.hash.hex() + args.transactionHash = event.hash.to_0x_hex() args.blockNumber = event.blockNumber - receipt = w3.eth.get_transaction_receipt(args.transactionHash) - # oDAO bootstrap doesn't emit an event if "odao_disable" in event_name and not args.confirmDisableBootstrapMode: return [] elif event_name == "pdao_set_delegate": + receipt = await w3.eth.get_transaction_receipt(args.transactionHash) args.delegator = receipt["from"] args.delegate = args.get("delegate") or args.get("newDelegate") - args.votingPower = solidity.to_float(rp.call("rocketNetworkVoting.getVotingPower", args.delegator, args.blockNumber)) + args.votingPower = solidity.to_float( + await rp.call( + "rocketNetworkVoting.getVotingPower", + args.delegator, + args.blockNumber, + ) + ) if (args.votingPower < 50) or (args.delegate == args.delegator): return [] elif "failed_deposit" in event_name: + receipt = await w3.eth.get_transaction_receipt(args.transactionHash) args.node = receipt["from"] args.burnedValue = solidity.to_float(event.gasPrice * receipt.gasUsed) elif "deposit_pool_queue" in event_name: + receipt = await w3.eth.get_transaction_receipt(args.transactionHash) args.node = receipt["from"] - event = rp.get_contract_by_name("rocketMinipoolQueue").events.MinipoolDequeued() + event = ( + await rp.get_contract_by_name("rocketMinipoolQueue") + ).events.MinipoolDequeued() # get the amount of dequeues that happened in this transaction using the event logs with warnings.catch_warnings(): warnings.simplefilter("ignore") - processed_logs = event.processReceipt(receipt) + processed_logs = event.process_receipt(receipt) args.count = len(processed_logs) elif "SettingBool" in args.function_name: args.value = bool(args.value) # this is duplicated for now because boostrap events are in events.py # and there is no good spot in utils for it elif event_name == "pdao_claimer": + def share_repr(percentage: float) -> str: max_width = 35 num_points = round(max_width * percentage / 100) - return '*' * num_points - - node_share = args.nodePercent / 10 ** 16 - pdao_share = args.protocolPercent / 10 ** 16 - odao_share = args.trustedNodePercent / 10 ** 16 - - args.description = '\n'.join([ - "Node Operator Share", - f"{share_repr(node_share)} {node_share:.1f}%", - "Protocol DAO Share", - f"{share_repr(pdao_share)} {pdao_share:.1f}%", - "Oracle DAO Share", - f"{share_repr(odao_share)} {odao_share:.1f}%", - ]) + return "*" * num_points + + node_share = args.nodePercent / 10**16 + pdao_share = args.protocolPercent / 10**16 + odao_share = args.trustedNodePercent / 10**16 + + args.description = "\n".join( + [ + "Node Operator Share", + f"{share_repr(node_share)} {node_share:.1f}%", + "Protocol DAO Share", + f"{share_repr(pdao_share)} {pdao_share:.1f}%", + "Oracle DAO Share", + f"{share_repr(odao_share)} {odao_share:.1f}%", + ] + ) elif event_name == "pdao_setting_multi": description_parts = [] for i in range(len(args.settingContractNames)): @@ -190,28 +216,32 @@ def share_repr(percentage: float) -> str: match args.types[i]: case 0: # SettingType.UINT256 - value = w3.toInt(value_raw) + value = w3.to_int(value_raw) case 1: # SettingType.BOOL value = bool(value_raw) case 2: # SettingType.ADDRESS - value = w3.toChecksumAddress(value_raw) + value = w3.to_checksum_address(value_raw) case _: value = "???" - description_parts.append( - f"`{args.settingPaths[i]}` set to `{value}`" - ) + description_parts.append(f"`{args.settingPaths[i]}` set to `{value}`") args.description = "\n".join(description_parts) elif event_name == "sdao_member_kick": - args.memberAddress = el_explorer_url(args.memberAddress, block=(args.blockNumber - 1)) + args.memberAddress = await el_explorer_url( + args.memberAddress, block=(args.blockNumber - 1) + ) elif event_name == "sdao_member_replace": - args.existingMemberAddress = el_explorer_url(args.existingMemberAddress, block=(args.blockNumber - 1)) + args.existingMemberAddress = await el_explorer_url( + args.existingMemberAddress, block=(args.blockNumber - 1) + ) elif event_name == "sdao_member_kick_multi": - args.member_list = ", ".join([ - el_explorer_url(member_address, block=(args.blockNumber - 1)) - for member_address in args.memberAddresses - ]) + args.member_list = ", ".join( + [ + await el_explorer_url(member_address, block=(args.blockNumber - 1)) + for member_address in args.memberAddresses + ] + ) elif event_name == "bootstrap_odao_network_upgrade": if args.type == "addContract": args.description = f"Contract `{args.name}` has been added!" @@ -227,40 +257,54 @@ def share_repr(percentage: float) -> str: embeds = [] for contract_name in args.contractNames: # (recipient, amount, period_length, start, periods_total, periods_paid) - get_contract = rp.get_function("rocketClaimDAO.getContract", contract_name) - contract_pre = get_contract.call(block_identifier=(args.blockNumber - 1)) - contract_post = get_contract.call(block_identifier=args.blockNumber) + get_contract = await rp.get_function( + "rocketClaimDAO.getContract", contract_name + ) + contract_pre = await get_contract.call( + block_identifier=(args.blockNumber - 1) + ) + contract_post = await get_contract.call( + block_identifier=args.blockNumber + ) args.contract_name = contract_name args.periodLength = contract_post[2] - + args.recipient_address = contract_post[0] periods_claimed = contract_post[5] - contract_pre[5] args.amount = periods_claimed * contract_post[1] periods_left: int = contract_post[4] - contract_post[5] if periods_left == 0: - args.contract_validity = "This was the final claim for this payment contract!" + args.contract_validity = ( + "This was the final claim for this payment contract!" + ) elif periods_left == 1: - args.contract_validity = "The contract is valid for one more period!" + args.contract_validity = ( + "The contract is valid for one more period!" + ) else: - args.contract_validity = f"The contract is valid for {periods_left} more periods." + args.contract_validity = ( + f"The contract is valid for {periods_left} more periods." + ) - embed = assemble(prepare_args(args)) + embed = await assemble(await prepare_args(args)) embeds.append(embed) return embeds - args = prepare_args(args) - return [assemble(args)] + args = await prepare_args(args) + return [await assemble(args)] - def process_transaction(self, block, tnx, contract_address, fn_input) -> list[Event]: + async def process_transaction( + self, block, tnx, contract_address, fn_input + ) -> list[Event]: if contract_address not in self.addresses: return [] contract_name = rp.get_name_by_address(contract_address) # get receipt and check if the transaction reverted using status attribute - receipt = w3.eth.get_transaction_receipt(tnx.hash) + receipt = await w3.eth.get_transaction_receipt(tnx.hash) if contract_name == "rocketNodeDeposit" and receipt.status: log.info(f"Skipping successful node deposit {tnx.hash.hex()}") return [] @@ -270,35 +314,41 @@ def process_transaction(self, block, tnx, contract_address, fn_input) -> list[Ev return [] try: - contract = rp.get_contract_by_address(contract_address) + contract = await rp.get_contract_by_address(contract_address) decoded = contract.decode_function_input(fn_input) except ValueError: log.error(f"Skipping transaction {tnx.hash.hex()} as it has invalid input") return [] log.debug(decoded) - function = decoded[0].function_identifier - if (event_name := self.function_map[contract_name].get(function)) is None: + function = decoded[0].abi_element_identifier + function_name = function.split("(")[0] + if (event_name := self.function_map[contract_name].get(function_name)) is None: return [] event = aDict(tnx) event.args = {arg.lstrip("_"): value for arg, value in decoded[1].items()} event.args["timestamp"] = block.timestamp - event.args["function_name"] = function + event.args["function_name"] = function_name if not receipt.status: - event.args["reason"] = rp.get_revert_reason(tnx) + event.args["reason"] = await rp.get_revert_reason(tnx) # if revert reason includes the phrase "insufficient for pre deposit" filter out if "insufficient for pre deposit" in event.args["reason"]: log.info(f"Skipping Insufficient Pre Deposit {tnx.hash.hex()}") return [] if event_name == "dao_proposal_execute": - dao_name = rp.call("rocketDAOProposal.getDAO", event.args["proposalID"]) + dao_name = await rp.call( + "rocketDAOProposal.getDAO", event.args["proposalID"] + ) # change prefix for DAO-specific event - event_name = event_name.replace("dao", { - "rocketDAONodeTrustedProposals": "odao", - "rocketDAOSecurityProposals": "sdao" - }[dao_name]) + event_name = event_name.replace( + "dao", + { + "rocketDAONodeTrustedProposals": "odao", + "rocketDAOSecurityProposals": "sdao", + }[dao_name], + ) responses = [] @@ -308,19 +358,23 @@ def process_transaction(self, block, tnx, contract_address, fn_input) -> list[Ev proposal_id = event.args["proposalID"] if "pdao" in event_name: dao = ProtocolDAO() - payload = rp.call("rocketDAOProtocolProposal.getPayload", proposal_id) + payload = await rp.call( + "rocketDAOProtocolProposal.getPayload", proposal_id + ) else: - dao = DefaultDAO(rp.call("rocketDAOProposal.getDAO", proposal_id)) - payload = rp.call("rocketDAOProposal.getPayload", proposal_id) + dao = DefaultDAO(await rp.call("rocketDAOProposal.getDAO", proposal_id)) + payload = await rp.call("rocketDAOProposal.getPayload", proposal_id) event.args["executor"] = event["from"] - proposal = dao.fetch_proposal(proposal_id) - event.args["proposal_body"] = dao.build_proposal_body(proposal, include_proposer=False) + proposal = await dao.fetch_proposal(proposal_id) + event.args["proposal_body"] = dao.build_proposal_body( + proposal, include_proposer=False + ) dao_address = dao.contract.address - responses = self.process_transaction(block, tnx, dao_address, payload) + responses = await self.process_transaction(block, tnx, dao_address, payload) - embeds = self.create_embeds(event_name, event) + embeds = await self.create_embeds(event_name, event) new_responses = [] for embed in embeds: @@ -336,11 +390,14 @@ def process_transaction(self, block, tnx, contract_address, fn_input) -> list[Ev new_responses.append(response) if "upgrade_triggered" in event_name: - log.info(f"Detected contract upgrade at block {response.block_number}, reinitializing") - rp.flush() + log.info( + f"Detected contract upgrade at block {response.block_number}, reinitializing" + ) + await rp.flush() self.__init__(self.bot) return new_responses + responses + async def setup(bot): await bot.add_cog(Transactions(bot)) diff --git a/rocketwatch/plugins/tvl/tvl.py b/rocketwatch/plugins/tvl/tvl.py index bdd08631..92f1e26c 100644 --- a/rocketwatch/plugins/tvl/tvl.py +++ b/rocketwatch/plugins/tvl/tvl.py @@ -1,36 +1,25 @@ import logging +from typing import Any import humanize from colorama import Style -from discord.app_commands import describe -from discord.ext import commands -from discord.ext.commands import Context, hybrid_command -from motor.motor_asyncio import AsyncIOMotorClient +from discord import Interaction +from discord.app_commands import command, describe +from discord.ext.commands import Cog from rocketwatch import RocketWatch from utils import solidity -from utils.cfg import cfg from utils.embeds import Embed from utils.readable import render_tree from utils.rocketpool import rp from utils.shared_w3 import w3 from utils.visibility import is_hidden -log = logging.getLogger("tvl") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.tvl") -def split_rewards_logic(balance, node_share, commission, force_base=False): - d = { - "base" : { - "reth": 0, - "node": 0 - }, - "rewards": { - "reth": 0, - "node": 0 - } - } +def minipool_split_rewards_logic(balance, node_share, commission, force_base=False): + d = {"base": {"reth": 0, "node": 0}, "rewards": {"reth": 0, "node": 0}} node_balance = 32 * node_share reth_balance = 32 - node_balance if balance >= 8 or force_base: @@ -48,382 +37,499 @@ def split_rewards_logic(balance, node_share, commission, force_base=False): return d -class TVL(commands.Cog): +def megapool_split_rewards( + rewards, capital_ratio, node_commission, voter_share, dao_share +): + borrowed_portion = rewards * (1 - capital_ratio) + reth_commission = 1 - node_commission - voter_share - dao_share + reth = borrowed_portion * reth_commission + voter = borrowed_portion * voter_share + dao = borrowed_portion * dao_share + node = rewards - reth - voter - dao + return {"node": node, "reth": reth, "voter": voter, "dao": dao} + + +class TVL(Cog): def __init__(self, bot: RocketWatch): self.bot = bot - self.db = AsyncIOMotorClient(cfg["mongodb.uri"]).get_database("rocketwatch") - @hybrid_command() + @command() @describe(show_all="Also show entries with 0 value") - async def tvl(self, - ctx: Context, - show_all: bool = False): + async def tvl(self, interaction: Interaction, show_all: bool = False): """ - Show the total value locked in the Protocol. + Show the total value locked in the protocol """ - await ctx.defer(ephemeral=is_hidden(ctx)) - data = { + await interaction.response.defer(ephemeral=is_hidden(interaction)) + data: dict[str, Any] = { "Total RPL Locked": { - "Staked RPL" : { - "Node Operators": {}, # accurate, live - "oDAO Bond" : {}, # accurate, live + "Staked RPL": { + "Minipools": {}, # accurate, live + "Megapools": {}, # accurate, live + "oDAO Bond": {}, # accurate, live }, "Unclaimed Rewards": { "Node Operators & oDAO": {}, # accurate, live - "pDAO" : {}, # accurate, live + "pDAO": {}, # accurate, live }, - "Slashed RPL" : {}, # accurate, live - "Unused Inflation" : {}, # accurate, live + "Slashed RPL": {}, # accurate, live + "Unused Inflation": {}, # accurate, live }, "Total ETH Locked": { - "Minipools Stake" : { - "Queued Minipools" : {}, # accurate, db - "Pending Minipools" : {}, # accurate, db + "Minipool Stake": { "Dissolved Minipools": { "Locked on Beacon Chain": {}, # accurate, db - "Contract Balance" : {}, # accurate, db + "Contract Balance": {}, # accurate, db }, - "Staking Minipools" : { - # beacon chain balances of staking minipools but ceil at 32 ETH and node share gets penalties first + "Staking Minipools": { "rETH Share": {"_val": 0}, # done, db "Node Share": {"_val": 0}, # done, db - } + }, + }, + "Megapool Stake": { + "Pending Validators": {}, + "Dissolved Validators": {}, + "Staking Validators": { + "rETH Share": {"_val": 0}, + "Node Share": {"_val": 0}, + }, + "Exiting Validators": { + "rETH Share": {"_val": 0}, + "Node Share": {"_val": 0}, + }, }, - "rETH Collateral" : { - "Deposit Pool" : {}, # accurate, live + "rETH Collateral": { + "Deposit Pool": {}, # accurate, live "Extra Collateral": {}, # accurate, live }, "Undistributed Balances": { - "Smoothing Pool Balance" : { - "rETH Share": {"_val": 0, "_is_estimate": True}, # missing - "Node Share": {"_val": 0, "_is_estimate": True}, # missing + "Smoothing Pool Balance": { + "rETH Share": {"_val": 0}, + "Node Share": {"_val": 0}, }, "Node Distributor Contracts": { "rETH Share": {"_val": 0}, # done, db "Node Share": {"_val": 0}, # done, db }, - "Minipool Contract Balances": { # important, only after minipool has gone to state "staking" + "Minipool Contract Balances": { "rETH Share": {"_val": 0}, # done, db "Node Share": {"_val": 0}, # done, db }, - "Beacon Chain Rewards" : { # anything over 32, split acording to node share - "rETH Share": {"_val": 0}, # done, db - "Node Share": {"_val": 0}, # done, db + "Megapool Contract Balances": { + "rETH Share": {"_val": 0}, + "Node Share": {"_val": 0}, + "Voter Share": {"_val": 0}, + "DAO Share": {"_val": 0}, + }, + "Beacon Chain Rewards": { + "rETH Share": {"_val": 0}, + "Node Share": {"_val": 0}, + "Voter Share": {"_val": 0}, + "DAO Share": {"_val": 0}, }, }, - "Unclaimed Rewards" : { - "Smoothing Pool": {}, # accurate, live - } + "Unclaimed Rewards": {}, # accurate, live }, } # note: _value in each dict will store the final string that gets rendered in the render - eth_price = rp.get_eth_usdc_price() - rpl_price = solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice")) - rpl_address = rp.get_address_by_name("rocketTokenRPL") - - # Queued Minipools: initialisedCount of minipool_count_per_status * 1 ETH. - # Minipools that are flagged as initialised have the following applied to them: - # - They have 1 ETH staked on the beacon chain. - # - They have not yet received 31 ETH from the Deposit Pool. - tmp = await self.db.minipools_new.aggregate([ - { - '$match': { - 'status': 'initialised', - 'vacant': False - } - }, { - '$group': { - '_id' : 'total', - 'beacon_balance': { - '$sum': 1 - } - } - } - ]).to_list(1) - if tmp: - data["Total ETH Locked"]["Minipools Stake"]["Queued Minipools"]["_val"] = tmp[0]["beacon_balance"] - - # Pending Minipools: prelaunchCount of minipool_count_per_status * 32 ETH. - # Minipools that are flagged as prelaunch have the following applied to them: - # - They have deposited 1 ETH to the Beacon Chain. - # - They have 31 ETH from the Deposit Pool in their contract waiting to be staked as well. - # - They are currently in the scrubbing process (should be 12 hours) or have not yet initiated the second phase. - tmp = await self.db.minipools_new.aggregate([ - { - '$match': { - 'status': 'prelaunch', - 'vacant': False - } - }, { - '$group': { - '_id' : 'total', - 'beacon_balance' : { - '$sum': 1 - }, - 'execution_balance': { - '$sum': "$execution_balance" - } - } - } - ]).to_list(1) - if tmp: - data["Total ETH Locked"]["Minipools Stake"]["Pending Minipools"]["_val"] = tmp[0]["beacon_balance"] + tmp[0][ - "execution_balance"] + eth_price = await rp.get_eth_usdc_price() + rpl_price = solidity.to_float(await rp.call("rocketNetworkPrices.getRPLPrice")) + rpl_address = await rp.get_address_by_name("rocketTokenRPL") # Dissolved Minipools: - # Minipools that are flagged as dissolved are Pending minipools that didn't trigger the second phase within the configured + # Minipools that are flagged as dissolved are Pending minipools that didn't + # trigger the second phase within the configured # LaunchTimeout (14 days at the time of writing). # They have the following applied to them: # - They have 1 ETH locked on the Beacon Chain, not earning any rewards. # - The 31 ETH that was waiting in their address was moved back to the Deposit Pool (This can cause the Deposit Pool - # to grow beyond its Cap, check the bellow comment for information about that). - tmp = await self.db.minipools_new.aggregate([ - { - '$match': { - 'status': 'dissolved', - 'vacant': False - } - }, { - '$group': { - '_id' : 'total', - 'beacon_balance' : { - '$sum': '$beacon.balance' + # to grow beyond its Cap, check the below comment for information about that). + tmp = await ( + await self.bot.db.minipools.aggregate( + [ + {"$match": {"status": "dissolved", "vacant": False}}, + { + "$group": { + "_id": "total", + "beacon_balance": {"$sum": "$beacon.balance"}, + "execution_balance": {"$sum": "$execution_balance"}, + } }, - 'execution_balance': { - '$sum': '$execution_balance' - } - } - } - ]).to_list(1) + ] + ) + ).to_list(1) if len(tmp) > 0: tmp = tmp[0] - data["Total ETH Locked"]["Minipools Stake"]["Dissolved Minipools"]["Locked on Beacon Chain"]["_val"] = tmp[ - "beacon_balance"] - data["Total ETH Locked"]["Minipools Stake"]["Dissolved Minipools"]["Contract Balance"]["_val"] = tmp[ - "execution_balance"] + data["Total ETH Locked"]["Minipool Stake"]["Dissolved Minipools"][ + "Locked on Beacon Chain" + ]["_val"] = tmp["beacon_balance"] + data["Total ETH Locked"]["Minipool Stake"]["Dissolved Minipools"][ + "Contract Balance" + ]["_val"] = tmp["execution_balance"] # Staking Minipools: - minipools = await self.db.minipools_new.find({ - 'status': {"$nin": ["initialised", "prelaunch", "dissolved"]}, - 'node_deposit_balance': {"$exists": True}, - }).to_list(None) + minipools = await self.bot.db.minipools.find( + { + "status": {"$nin": ["initialised", "prelaunch", "dissolved"]}, + "node_deposit_balance": {"$exists": True}, + } + ).to_list(None) for minipool in minipools: node_share = minipool["node_deposit_balance"] / 32 commission = minipool["node_fee"] refund_balance = minipool["node_refund_balance"] contract_balance = minipool["execution_balance"] - beacon_balance = minipool["beacon"]["balance"] if "beacon" in minipool else 32 + beacon_balance = ( + minipool["beacon"]["balance"] if "beacon" in minipool else 32 + ) # if there is a refund_balance, we first try to pay that off using the contract balance if refund_balance > 0: if contract_balance > 0: if contract_balance >= refund_balance: contract_balance -= refund_balance - data["Total ETH Locked"]["Undistributed Balances"]["Minipool Contract Balances"]["Node Share"][ - "_val"] += refund_balance + data["Total ETH Locked"]["Undistributed Balances"][ + "Minipool Contract Balances" + ]["Node Share"]["_val"] += refund_balance refund_balance = 0 else: refund_balance -= contract_balance - data["Total ETH Locked"]["Undistributed Balances"]["Minipool Contract Balances"]["Node Share"][ - "_val"] += contract_balance + data["Total ETH Locked"]["Undistributed Balances"][ + "Minipool Contract Balances" + ]["Node Share"]["_val"] += contract_balance contract_balance = 0 # if there is still a refund balance, we try to pay it off using the beacon balance - if refund_balance > 0: - if beacon_balance > 0: - if beacon_balance >= refund_balance: - beacon_balance -= refund_balance - data["Total ETH Locked"]["Minipools Stake"]["Staking Minipools"]["Node Share"][ - "_val"] += refund_balance - refund_balance = 0 - else: - refund_balance -= beacon_balance - data["Total ETH Locked"]["Minipools Stake"]["Staking Minipools"]["Node Share"][ - "_val"] += beacon_balance - beacon_balance = 0 - beacon_rewards = max(0, beacon_balance - 32) + if refund_balance > 0 and beacon_balance > 0: + if beacon_balance >= refund_balance: + beacon_balance -= refund_balance + data["Total ETH Locked"]["Minipool Stake"]["Staking Minipools"][ + "Node Share" + ]["_val"] += refund_balance + refund_balance = 0 + else: + refund_balance -= beacon_balance + data["Total ETH Locked"]["Minipool Stake"]["Staking Minipools"][ + "Node Share" + ]["_val"] += beacon_balance + beacon_balance = 0 if beacon_balance > 0: - d = split_rewards_logic(beacon_balance, node_share, commission, force_base=True) - data["Total ETH Locked"]["Minipools Stake"]["Staking Minipools"]["Node Share"]["_val"] += d["base"]["node"] - data["Total ETH Locked"]["Minipools Stake"]["Staking Minipools"]["rETH Share"]["_val"] += d["base"]["reth"] - data["Total ETH Locked"]["Undistributed Balances"]["Beacon Chain Rewards"]["Node Share"]["_val"] += \ - d["rewards"]["node"] - data["Total ETH Locked"]["Undistributed Balances"]["Beacon Chain Rewards"]["rETH Share"]["_val"] += \ - d["rewards"]["reth"] + d = minipool_split_rewards_logic( + beacon_balance, node_share, commission, force_base=True + ) + data["Total ETH Locked"]["Minipool Stake"]["Staking Minipools"][ + "Node Share" + ]["_val"] += d["base"]["node"] + data["Total ETH Locked"]["Minipool Stake"]["Staking Minipools"][ + "rETH Share" + ]["_val"] += d["base"]["reth"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Beacon Chain Rewards" + ]["Node Share"]["_val"] += d["rewards"]["node"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Beacon Chain Rewards" + ]["rETH Share"]["_val"] += d["rewards"]["reth"] if contract_balance > 0: - d = split_rewards_logic(contract_balance, node_share, commission) - data["Total ETH Locked"]["Undistributed Balances"]["Minipool Contract Balances"]["Node Share"][ - "_val"] += d["base"]["node"] + d["rewards"]["node"] - data["Total ETH Locked"]["Undistributed Balances"]["Minipool Contract Balances"]["rETH Share"][ - "_val"] += d["base"]["reth"] + d["rewards"]["reth"] + d = minipool_split_rewards_logic( + contract_balance, node_share, commission + ) + data["Total ETH Locked"]["Undistributed Balances"][ + "Minipool Contract Balances" + ]["Node Share"]["_val"] += d["base"]["node"] + d["rewards"]["node"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Minipool Contract Balances" + ]["rETH Share"]["_val"] += d["base"]["reth"] + d["rewards"]["reth"] + + # Megapool commission settings + network_settings = await rp.get_contract_by_name( + "rocketDAOProtocolSettingsNetwork" + ) + node_share = solidity.to_float( + await network_settings.functions.getNodeShare().call() + ) + voter_share = solidity.to_float( + await network_settings.functions.getVoterShare().call() + ) + dao_share = solidity.to_float( + await network_settings.functions.getProtocolDAOShare().call() + ) + + # Pending Megapool Validators: prestaked validators have deposit_value locked + # (1 ETH on beacon + 31 ETH in contract as assignedValue) + # in_queue validators are skipped — their ETH is in the Deposit Pool (already counted) + tmp = await ( + await self.bot.db.megapool_validators.aggregate( + [{"$match": {"status": "prestaked"}}, {"$count": "count"}] + ) + ).to_list(1) + if tmp: + data["Total ETH Locked"]["Megapool Stake"]["Pending Validators"]["_val"] = ( + tmp[0]["count"] * 32 + ) + + # Dissolved Megapool Validators: 1 ETH stuck on beacon chain, 31 ETH returned to DP + tmp = await ( + await self.bot.db.megapool_validators.aggregate( + [ + {"$match": {"status": "dissolved"}}, + { + "$group": { + "_id": "total", + "beacon_balance": {"$sum": "$beacon.balance"}, + } + }, + ] + ) + ).to_list(1) + if tmp: + data["Total ETH Locked"]["Megapool Stake"]["Dissolved Validators"][ + "_val" + ] = tmp[0]["beacon_balance"] + + # Staking, Locked & Exiting Megapool Validators: beacon balance split by capital ratio + # locked = exit requested but not yet confirmed on beacon chain, treated as exiting + megapool_validators = await self.bot.db.megapool_validators.find( + {"status": {"$in": ["staking", "locked", "exiting"]}} + ).to_list(None) + for v in megapool_validators: + capital_ratio = v["requested_bond"] / 32 + beacon_balance = v.get("beacon", {}).get("balance", 32) + status = v["status"] + # base stake (up to 32 ETH) + base = min(beacon_balance, 32) + node_base = v["requested_bond"] + # handle penalties (beacon < 32): node absorbs losses first + if base < 32: + shortfall = 32 - base + node_base = max(0, node_base - shortfall) + reth_base = base - node_base + target = ( + "Staking Validators" if (status == "staking") else "Exiting Validators" + ) + data["Total ETH Locked"]["Megapool Stake"][target]["rETH Share"][ + "_val" + ] += reth_base + data["Total ETH Locked"]["Megapool Stake"][target]["Node Share"][ + "_val" + ] += node_base + # beacon chain rewards (anything over 32) + if beacon_balance > 32: + rewards = beacon_balance - 32 + split = megapool_split_rewards( + rewards, capital_ratio, node_share, voter_share, dao_share + ) + data["Total ETH Locked"]["Undistributed Balances"][ + "Beacon Chain Rewards" + ]["Node Share"]["_val"] += split["node"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Beacon Chain Rewards" + ]["rETH Share"]["_val"] += split["reth"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Beacon Chain Rewards" + ]["Voter Share"]["_val"] += split["voter"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Beacon Chain Rewards" + ]["DAO Share"]["_val"] += split["dao"] + + # Megapool Contract Balances: eth_balance = assignedValue + refundValue + pendingRewards + # assignedValue already counted in Queued Validators, so we split the rest: + # refundValue (minus debt) → Node Share + # pendingRewards → split by commission (node/rETH/voter/DAO) + megapool_balances = await ( + await self.bot.db.node_operators.aggregate( + [ + { + "$match": { + "megapool.deployed": True, + "megapool.eth_balance": {"$gt": 0}, + } + }, + { + "$project": { + "refund_value": "$megapool.refund_value", + "debt": "$megapool.debt", + "pending_rewards": "$megapool.pending_rewards", + "node_bond": "$megapool.node_bond", + "user_capital": "$megapool.user_capital", + } + }, + ] + ) + ).to_list() + for mp in megapool_balances: + refund_value = mp.get("refund_value", 0) + debt_val = mp.get("debt", 0) + pending_rewards = mp.get("pending_rewards", 0) + # refundValue minus debt → Node Share + node_refund = max(0, refund_value - debt_val) + data["Total ETH Locked"]["Undistributed Balances"][ + "Megapool Contract Balances" + ]["Node Share"]["_val"] += node_refund + # pendingRewards → split by commission + if pending_rewards > 0: + total_capital = mp.get("node_bond", 0) + mp.get("user_capital", 0) + capital_ratio = ( + mp.get("node_bond", 0) / total_capital if total_capital > 0 else 0 + ) + split = megapool_split_rewards( + pending_rewards, capital_ratio, node_share, voter_share, dao_share + ) + data["Total ETH Locked"]["Undistributed Balances"][ + "Megapool Contract Balances" + ]["Node Share"]["_val"] += split["node"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Megapool Contract Balances" + ]["rETH Share"]["_val"] += split["reth"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Megapool Contract Balances" + ]["Voter Share"]["_val"] += split["voter"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Megapool Contract Balances" + ]["DAO Share"]["_val"] += split["dao"] # Deposit Pool Balance: calls the contract and asks what its balance is, simple enough. # ETH in here has been swapped for rETH and is waiting to be matched with a minipool. # Fun Fact: This value can go above the configured Deposit Pool Cap in 2 scenarios: # - A Minipool gets dissolved, moving 16 ETH from its address back to the Deposit Pool. - # - ETH from withdrawn Minipools, which gets stored in the rETH contract, surpasses the configured targetCollateralRate, - # which is 10% at the time of writing. Once this occurs the ETH gets moved from the rETH contract to the Deposit Pool. - data["Total ETH Locked"]["rETH Collateral"]["Deposit Pool"]["_val"] = solidity.to_float( - rp.call("rocketDepositPool.getBalance")) + # - ETH from withdrawn Minipools, which gets stored in the rETH contract, + # surpasses the configured targetCollateralRate, + # which is 10% at the time of writing. Once this occurs the ETH gets moved + # from the rETH contract to the Deposit Pool. + data["Total ETH Locked"]["rETH Collateral"]["Deposit Pool"]["_val"] = ( + solidity.to_float(await rp.call("rocketDepositPool.getBalance")) + ) # Extra Collateral: This is ETH stored in the rETH contract from Minipools that have been withdrawn from. # This value has a cap - read the above comment for more information about that. - data["Total ETH Locked"]["rETH Collateral"]["Extra Collateral"]["_val"] = solidity.to_float( - w3.eth.getBalance(rp.get_address_by_name("rocketTokenRETH"))) + data["Total ETH Locked"]["rETH Collateral"]["Extra Collateral"]["_val"] = ( + solidity.to_float( + await w3.eth.get_balance( + await rp.get_address_by_name("rocketTokenRETH") + ) + ) + ) # Smoothing Pool Balance: This is ETH from Proposals by minipools that have joined the Smoothing Pool. - smoothie_balance = solidity.to_float(w3.eth.getBalance(rp.get_address_by_name("rocketSmoothingPool"))) - tmp = await self.db.node_operators_new.aggregate([ - { - '$match': { - 'smoothing_pool_registration_state': True, - 'staking_minipool_count' : { - '$ne': 0 - } - } - }, { - '$project': { - 'staking_minipool_count': 1, - 'effective_node_share' : 1, - 'node_share' : { - '$sum': [ - '$effective_node_share', { - '$multiply': [ - { - '$subtract': [ - 1, '$effective_node_share' - ] - }, '$average_node_fee' - ] - } - ] - } - } - }, { - '$group': { - '_id' : None, - 'node_share': { - '$sum': { - '$multiply': [ - '$node_share', '$staking_minipool_count', '$effective_node_share' - ] - } - }, - 'count' : { - '$sum': { - '$multiply': [ - '$staking_minipool_count', '$effective_node_share' - ] - } - } - } - }, { - '$project': { - 'avg_node_share': { - '$divide': [ - '$node_share', '$count' - ] - } - } - } - ]).to_list(None) - if len(tmp) > 0: - data["Total ETH Locked"]["Undistributed Balances"]["Smoothing Pool Balance"]["Node Share"][ - "_val"] = smoothie_balance * tmp[0]["avg_node_share"] - data["Total ETH Locked"]["Undistributed Balances"]["Smoothing Pool Balance"]["rETH Share"][ - "_val"] = smoothie_balance * (1 - tmp[0]["avg_node_share"]) + smoothie_balance = solidity.to_float( + await w3.eth.get_balance( + await rp.get_address_by_name("rocketSmoothingPool") + ) + ) + data["Total ETH Locked"]["Undistributed Balances"]["Smoothing Pool Balance"][ + "_val" + ] = smoothie_balance # Unclaimed Smoothing Pool Rewards: This is ETH from the previous Reward Periods that have not been claimed yet. - data["Total ETH Locked"]["Unclaimed Rewards"]["Smoothing Pool"]["_val"] = solidity.to_float( - rp.call("rocketVault.balanceOf", "rocketMerkleDistributorMainnet")) + data["Total ETH Locked"]["Unclaimed Rewards"]["_val"] = solidity.to_float( + await rp.call("rocketVault.balanceOf", "rocketMerkleDistributorMainnet") + ) - # Staked RPL: This is all ETH that has been staked by Node Operators. - data["Total RPL Locked"]["Staked RPL"]["Node Operators"]["_val"] = solidity.to_float( - rp.call("rocketNodeStaking.getTotalRPLStake")) + # Staked RPL: This is all ETH that has been staked by node operators. + data["Total RPL Locked"]["Staked RPL"]["Minipools"]["_val"] = solidity.to_float( + await rp.call("rocketNodeStaking.getTotalLegacyStakedRPL") + ) + data["Total RPL Locked"]["Staked RPL"]["Megapools"]["_val"] = solidity.to_float( + await rp.call("rocketNodeStaking.getTotalMegapoolStakedRPL") + ) # oDAO bonded RPL: RPL oDAO Members have to lock up to join it. This RPL can be slashed if they misbehave. data["Total RPL Locked"]["Staked RPL"]["oDAO Bond"]["_val"] = solidity.to_float( - rp.call("rocketVault.balanceOfToken", "rocketDAONodeTrustedActions", rpl_address)) + await rp.call( + "rocketVault.balanceOfToken", "rocketDAONodeTrustedActions", rpl_address + ) + ) # Unclaimed RPL Rewards: RPL rewards that have been earned by Node Operators but have not been claimed yet. - data["Total RPL Locked"]["Unclaimed Rewards"]["Node Operators & oDAO"]["_val"] = solidity.to_float( - rp.call("rocketVault.balanceOfToken", "rocketMerkleDistributorMainnet", rpl_address)) + data["Total RPL Locked"]["Unclaimed Rewards"]["Node Operators & oDAO"][ + "_val" + ] = solidity.to_float( + await rp.call( + "rocketVault.balanceOfToken", + "rocketMerkleDistributorMainnet", + rpl_address, + ) + ) # Undistributed pDAO Rewards: RPL rewards that have been earned by the pDAO but have not been distributed yet. - data["Total RPL Locked"]["Unclaimed Rewards"]["pDAO"]["_val"] = solidity.to_float( - rp.call("rocketVault.balanceOfToken", "rocketClaimDAO", rpl_address)) + data["Total RPL Locked"]["Unclaimed Rewards"]["pDAO"]["_val"] = ( + solidity.to_float( + await rp.call( + "rocketVault.balanceOfToken", "rocketClaimDAO", rpl_address + ) + ) + ) # Unused Inflation: RPL that has been minted but not yet been used for rewards. # This is (or was) an issue as the snapshots didn't account for the last day of inflation. # Joe is already looking into this. data["Total RPL Locked"]["Unused Inflation"]["_val"] = solidity.to_float( - rp.call("rocketVault.balanceOfToken", "rocketRewardsPool", rpl_address)) + await rp.call( + "rocketVault.balanceOfToken", "rocketRewardsPool", rpl_address + ) + ) # Slashed RPL: RPL that is slashed gets moved to the Auction Manager Contract. # This RPL will be sold using a Dutch Auction for ETH, which the gets moved to the rETH contract to be used as # extra rETH collateral. data["Total RPL Locked"]["Slashed RPL"]["_val"] = solidity.to_float( - rp.call("rocketVault.balanceOfToken", "rocketAuctionManager", rpl_address)) + await rp.call( + "rocketVault.balanceOfToken", "rocketAuctionManager", rpl_address + ) + ) # create _value string for each branch. the _value is the sum of all _val or _val values in the children - tmp = await self.db.node_operators_new.aggregate([ - { - '$match': { - 'fee_distributor_eth_balance': { - '$gt': 0 - } - } - }, { - '$project': { - 'fee_distributor_eth_balance': 1, - 'node_share' : { - '$sum': [ - '$effective_node_share', { - '$multiply': [ + tmp = await ( + await self.bot.db.node_operators.aggregate( + [ + {"$match": {"fee_distributor.eth_balance": {"$gt": 0}}}, + { + "$project": { + "fee_distributor.eth_balance": 1, + "node_share": { + "$sum": [ + "$effective_node_share", { - '$subtract': [ - 1, '$effective_node_share' + "$multiply": [ + {"$subtract": [1, "$effective_node_share"]}, + "$average_node_fee", ] - }, '$average_node_fee' + }, ] - } - ] - } - } - }, { - '$project': { - 'node_share': { - '$multiply': [ - '$fee_distributor_eth_balance', '$node_share' - ] + }, + } }, - 'reth_share': { - '$multiply': [ - '$fee_distributor_eth_balance', { - '$subtract': [ - 1, '$node_share' + { + "$project": { + "node_share": { + "$multiply": [ + "$fee_distributor.eth_balance", + "$node_share", + ] + }, + "reth_share": { + "$multiply": [ + "$fee_distributor.eth_balance", + {"$subtract": [1, "$node_share"]}, ] - } - ] - } - } - }, { - '$group': { - '_id' : None, - 'node_share': { - '$sum': '$node_share' + }, + } }, - 'reth_share': { - '$sum': '$reth_share' - } - } - } - ]).to_list(None) + { + "$group": { + "_id": None, + "node_share": {"$sum": "$node_share"}, + "reth_share": {"$sum": "$reth_share"}, + } + }, + ] + ) + ).to_list() if len(tmp) > 0: - data["Total ETH Locked"]["Undistributed Balances"]["Node Distributor Contracts"]["Node Share"]["_val"] = tmp[0][ - "node_share"] - data["Total ETH Locked"]["Undistributed Balances"]["Node Distributor Contracts"]["rETH Share"]["_val"] = tmp[0][ - "reth_share"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Node Distributor Contracts" + ]["Node Share"]["_val"] = tmp[0]["node_share"] + data["Total ETH Locked"]["Undistributed Balances"][ + "Node Distributor Contracts" + ]["rETH Share"]["_val"] = tmp[0]["reth_share"] def set_val_of_branch(branch, unit): val = 0 @@ -445,16 +551,18 @@ def set_val_of_branch(branch, unit): set_val_of_branch(data["Total ETH Locked"], "ETH") set_val_of_branch(data["Total RPL Locked"], "RPL") # calculate total tvl - total_tvl = data["Total ETH Locked"]["_val"] + (data["Total RPL Locked"]["_val"] * rpl_price) + total_tvl = data["Total ETH Locked"]["_val"] + ( + data["Total RPL Locked"]["_val"] * rpl_price + ) usdc_total_tvl = total_tvl * eth_price data["_value"] = f"{total_tvl:,.2f} ETH" test = render_tree(data, "Total Locked Value", max_depth=0 if show_all else 2) # send embed with tvl - e = Embed() - closer = f"or about {Style.BRIGHT}{humanize.intword(usdc_total_tvl, format='%.3f')} USDC{Style.RESET_ALL}".rjust(max([len(line) for line in test.split("\n")])-1) - e.description = f"```ansi\n{test}\n{closer}```" - e.set_footer(text="\"that looks good to me\" - invis 2023") - await ctx.send(embed=e) + closer = f"or about {Style.BRIGHT}{humanize.intword(usdc_total_tvl, format='%.3f')} USDC{Style.RESET_ALL}".rjust( + max([len(line) for line in test.split("\n")]) - 1 + ) + embed = Embed(title="Protocol TVL", description=f"```ansi\n{test}\n{closer}```") + await interaction.followup.send(embed=embed) async def setup(bot): diff --git a/rocketwatch/plugins/user_distribute/user_distribute.py b/rocketwatch/plugins/user_distribute/user_distribute.py new file mode 100644 index 00000000..a798579b --- /dev/null +++ b/rocketwatch/plugins/user_distribute/user_distribute.py @@ -0,0 +1,247 @@ +import logging +import time +from operator import itemgetter + +from discord import ButtonStyle, Interaction, ui +from discord.abc import Messageable +from discord.app_commands import command +from discord.ext import commands, tasks +from pymongo import ASCENDING + +from rocketwatch import RocketWatch +from utils.config import cfg +from utils.embeds import Embed +from utils.file import TextFile +from utils.rocketpool import rp +from utils.shared_w3 import bacon, w3 +from utils.visibility import is_hidden + +log = logging.getLogger("rocketwatch.user_distribute") + + +class InstructionsView(ui.View): + def __init__( + self, eligible: list[dict], distributable: list[dict], instruction_timeout: int + ): + super().__init__(timeout=instruction_timeout) + self.eligible = eligible + self.distributable = distributable + + @ui.button(label="Instructions", style=ButtonStyle.blurple) + async def instructions(self, interaction: Interaction, _) -> None: + mp_contract = await rp.assemble_contract("rocketMinipoolDelegate") + bud_calldata = bytes.fromhex( + mp_contract.encode_abi(abi_element_identifier="beginUserDistribute")[2:] + ) + dist_calldata = bytes.fromhex( + mp_contract.encode_abi( + abi_element_identifier="distributeBalance", args=[False] + )[2:] + ) + + calls = [(mp["address"], False, dist_calldata) for mp in self.distributable] + calls += [(mp["address"], False, bud_calldata) for mp in self.eligible] + + multicall_contract = await rp.get_contract_by_name("multicall3") + gas_used = await multicall_contract.functions.aggregate3(calls).estimate_gas() + gas_price = await w3.eth.gas_price + cost_eth = gas_used * gas_price / 1e18 + + tuple_strs = [] + for address, allow_failure, calldata in calls: + tuple_strs.append( + f'["{address}", {str(allow_failure).lower()}, "0x{calldata.hex()}"]' + ) + + input_data = "[" + ",".join(tuple_strs) + "]" + etherscan_url = f"https://etherscan.io/address/{multicall_contract.address}#writeContract#F2" + + embed = Embed(title="Distribution Instructions") + embed.description = ( + f"1. Open the [Multicall `aggregate3` function]({etherscan_url}) on Etherscan\n" + f"2. Enter `0` for `payableAmount (ether)`\n" + f"3. Paste the provided input data into the `calls (tuple[])` field\n" + f"4. Connect your wallet (`Connect to Web3`)\n" + f"5. Click `Write` and sign with your wallet\n" + ) + + actions = [] + if (count := len(self.distributable)) > 0: + actions.append( + f"distribute the balance of **{count}** minipool{'s' if count != 1 else ''}" + ) + if (count := len(self.eligible)) > 0: + actions.append( + f"begin the user distribution process for **{count}** minipool{'s' if count != 1 else ''}" + ) + + embed.description += "\nThis will " + " and ".join(actions) + "." + embed.description += f"\nEstimated cost: **{cost_eth:,.6f} ETH** ({gas_used:,} gas @ {(gas_price / 1e9):.2f} gwei)" + + await interaction.response.send_message( + embed=embed, + file=TextFile(input_data, "input_data.txt"), + ephemeral=True, + ) + + +class UserDistribute(commands.Cog): + def __init__(self, bot: RocketWatch): + self.bot = bot + self.task.start() + + async def cog_unload(self): + self.task.cancel() + + @tasks.loop(hours=8) + async def task(self): + channel_id = cfg.discord.channels.get("user_distribute") + if not channel_id: + return + + channel = await self.bot.get_or_fetch_channel(channel_id) + assert isinstance(channel, Messageable) + + _, _, distributable = await self._fetch_minipools() + if not distributable: + return + + embed = Embed(title=":hourglass_flowing_sand: User Distribution Window Open") + count = len(distributable) + next_window_close = distributable[0]["ud_window_close"] + embed.description = ( + f"There {'are' if count != 1 else 'is'} **{count}**" + f" minipool{'s' if count != 1 else ''} ready for distribution.\n" + f"The next window closes !" + ) + + await channel.send( + embed=embed, + view=InstructionsView( + [], distributable[:100], instruction_timeout=(4 * 3600) + ), + ) + + @task.before_loop + async def before_task(self): + await self.bot.wait_until_ready() + + @task.error + async def on_task_error(self, err: BaseException): + assert isinstance(err, Exception) + await self.bot.report_error(err) + + async def _fetch_minipools(self) -> tuple[list[dict], list[dict], list[dict]]: + head = await bacon.get_block_header("head") + current_epoch = int(head["data"]["header"]["message"]["slot"]) // 32 + threshold_epoch = current_epoch - 5000 + + minipools = ( + await self.bot.db.minipools.find( + { + "user_distributed": False, + "status": "staking", + "execution_balance": {"$gte": 8}, + "beacon.withdrawable_epoch": {"$lt": threshold_epoch}, + } + ) + .sort("beacon.withdrawable_epoch", ASCENDING) + .to_list() + ) + + eligible = [] + pending = [] + distributable = [] + + current_time = int(time.time()) + ud_window_start = await rp.call( + "rocketDAOProtocolSettingsMinipool.getUserDistributeWindowStart" + ) + ud_window_end = ud_window_start + await rp.call( + "rocketDAOProtocolSettingsMinipool.getUserDistributeWindowLength" + ) + + for mp in minipools: + mp["address"] = w3.to_checksum_address(mp["address"]) + storage = await w3.eth.get_storage_at(mp["address"], 0x17) + user_distribute_time: int = int.from_bytes(storage, "big") + elapsed_time = current_time - user_distribute_time + + if elapsed_time < ud_window_start: + mp["ud_window_open"] = user_distribute_time + ud_window_start + pending.append(mp) + elif elapsed_time < ud_window_end: + mp["ud_window_close"] = user_distribute_time + ud_window_end + distributable.append(mp) + else: + # double check, DB may lag behind + minipool_contract = await rp.assemble_contract( + "rocketMinipool", address=mp["address"] + ) + if await minipool_contract.functions.getUserDistributed().call(): + continue + if await minipool_contract.functions.getFinalised().call(): + continue + + eligible.append(mp) + + pending.sort(key=itemgetter("ud_window_open")) + distributable.sort(key=itemgetter("ud_window_close")) + + return eligible, pending, distributable + + @command() + async def user_distribute_status(self, interaction: Interaction): + """Show user distribute summary for minipools""" + await interaction.response.defer(ephemeral=is_hidden(interaction)) + + eligible, pending, distributable = await self._fetch_minipools() + + embed = Embed(title="User Distribute Status") + + embed.add_field( + name="Eligible", + value=f"**{len(eligible)}** minipool{'s' if len(eligible) != 1 else ''}", + inline=False, + ) + + if pending: + next_window_open = pending[0]["ud_window_open"] + embed.add_field( + name="Pending", + value=( + f"**{len(pending)}** minipool{'s' if len(pending) != 1 else ''}" + f" · next window opens " + ), + inline=False, + ) + else: + embed.add_field(name="Pending", value="**0** minipools", inline=False) + + if distributable: + next_window_close = distributable[0]["ud_window_close"] + embed.add_field( + name="Distributable", + value=( + f"**{len(distributable)}** minipool{'s' if len(distributable) != 1 else ''}" + f" · next window closes " + ), + inline=False, + ) + else: + embed.add_field(name="Distributable", value="**0** minipools", inline=False) + + if eligible or distributable: + # limit the number of distributions to not run out of gas + await interaction.followup.send( + embed=embed, + view=InstructionsView( + eligible[:50], distributable[:100], instruction_timeout=1800 + ), + ) + else: + await interaction.followup.send(embed=embed) + + +async def setup(bot): + await bot.add_cog(UserDistribute(bot)) diff --git a/rocketwatch/plugins/validator_states/validator_states.py b/rocketwatch/plugins/validator_states/validator_states.py new file mode 100644 index 00000000..49e84e5f --- /dev/null +++ b/rocketwatch/plugins/validator_states/validator_states.py @@ -0,0 +1,238 @@ +import logging + +from discord import Interaction +from discord.app_commands import command +from discord.ext import commands + +from rocketwatch import RocketWatch +from utils.embeds import Embed, el_explorer_url +from utils.readable import render_tree_legacy +from utils.shared_w3 import w3 +from utils.visibility import is_hidden + +log = logging.getLogger("rocketwatch.validator_states") + + +_BEACON_PENDING = { + "in_queue": "unassigned", + "prestaked": "prestaked", + "staking": "staked", +} + + +def _classify_beacon_validator(beacon, contract_status): + """Classify a validator by beacon status. Returns (status, sub_status).""" + match beacon["status"]: + case "pending_initialized": + if contract_status == "dissolved": + return "dissolved", None + else: + return "pending", _BEACON_PENDING[contract_status] + case "pending_queued": + return "pending", "queued" + case "active_ongoing": + return "active", "ongoing" + case "active_exiting": + return "exiting", "voluntarily" + case "active_slashed": + return "exiting", "slashed" + case "exited_unslashed" | "exited_slashed" | "withdrawal_possible": + sub = "slashed" if beacon["slashed"] else "voluntarily" + return "exited", sub + case "withdrawal_done": + sub = "slashed" if beacon["slashed"] else "unslashed" + return "withdrawn", sub + case _: + log.warning(f"Unknown beacon status {beacon['status']}") + return None, None + + +def _empty_state_tree(): + return { + "dissolved": 0, + "pending": {}, + "active": {}, + "exiting": {}, + "exited": {}, + "withdrawn": {}, + "closed": {}, + } + + +def _classify_collection(docs, done_fn): + """Classify docs into state tree. + + Args: + docs: list of DB documents, with or without beacon data + done_fn: function that takes a doc and returns True if its lifecycle is complete + (used to distinguish withdrawn vs closed for withdrawal_done validators) + """ + data = _empty_state_tree() + exiting_valis = [] + withdrawn_valis = [] + + for doc in docs: + beacon = doc.get("beacon") + contract_status = doc.get("status", "") + + if beacon is None: + sub = _BEACON_PENDING.get(contract_status) + if sub: + data["pending"][sub] = data["pending"].get(sub, 0) + 1 + elif contract_status == "dissolved": + data["dissolved"] += 1 + continue + + category, sub = _classify_beacon_validator(beacon, contract_status) + if category is None: + continue + if category == "withdrawn" and done_fn(doc): + category = "closed" + if category == "dissolved": + data["dissolved"] += 1 + else: + data[category][sub] = data[category].get(sub, 0) + 1 + if category in ("exiting", "exited"): + exiting_valis.append(doc) + elif category == "withdrawn": + withdrawn_valis.append(doc) + + return data, exiting_valis, withdrawn_valis + + +def _collapse_tree(data: dict) -> dict: + collapsed_data = {} + for status in data: + if isinstance(data[status], dict) and len(data[status]) == 1: + sub_status = next(iter(data[status].keys())) + collapsed_data[status] = data[status][sub_status] + else: + collapsed_data[status] = data[status] + return collapsed_data + + +class ValidatorStates(commands.Cog): + def __init__(self, bot: RocketWatch): + self.bot = bot + + @command() + async def validator_states(self, interaction: Interaction): + """Show validator counts by beacon chain and contract status""" + await interaction.response.defer(ephemeral=is_hidden(interaction)) + + minipools = await self.bot.db.minipools.find( + {"beacon.status": {"$exists": True}}, + { + "beacon": 1, + "status": 1, + "finalized": 1, + "node_operator": 1, + "validator_index": 1, + }, + ).to_list(None) + megapool_vals = await self.bot.db.megapool_validators.find( + {}, {"beacon": 1, "status": 1, "node_operator": 1, "validator_index": 1} + ).to_list(None) + + mp_data, mp_exiting, mp_withdrawn = _classify_collection( + minipools, lambda d: d.get("finalized", False) + ) + mg_data, mg_exiting, mg_withdrawn = _classify_collection( + megapool_vals, lambda d: d.get("status") == "exited" + ) + + tree = { + "minipools": _collapse_tree(mp_data), + "megapools": _collapse_tree(mg_data), + } + + embed = Embed(title="Validator States", color=0x00FF00) + description = "```\n" + description += render_tree_legacy(tree, "Validators") + + exiting_valis = mp_exiting + mg_exiting + withdrawn_valis = mp_withdrawn + mg_withdrawn + total_listed_valis = len(exiting_valis) + len(withdrawn_valis) + + if total_listed_valis == 0: + description += "```" + elif total_listed_valis < 24: + description += "\n" + if exiting_valis: + description += "\n--- Exiting Validators ---\n\n" + valis = sorted([v["validator_index"] for v in exiting_valis]) + description += ", ".join([str(v) for v in valis]) + if withdrawn_valis: + description += "\n--- Withdrawn Validators ---\n\n" + valis = sorted([v["validator_index"] for v in withdrawn_valis]) + description += ", ".join([str(v) for v in valis]) + description += "```" + else: + description += "```" + + node_operators = [] + for valis in (exiting_valis, withdrawn_valis): + valis_no: dict[str, int] = {} + for v in valis: + no = v["node_operator"] + valis_no[no] = valis_no.get(no, 0) + 1 + valis_no_sorted = sorted( + valis_no.items(), key=lambda x: x[1], reverse=True + ) + node_operators.append(valis_no_sorted) + + exiting_node_operators, withdrawn_node_operators = node_operators + max_total_list_length = 16 + + if ( + len(exiting_node_operators) + len(withdrawn_node_operators) + <= max_total_list_length + ): + num_exiting = len(exiting_node_operators) + num_withdrawn = len(withdrawn_node_operators) + elif len(exiting_node_operators) >= len(withdrawn_node_operators): + num_withdrawn = min( + len(withdrawn_node_operators), max_total_list_length // 2 + ) + num_exiting = max_total_list_length - num_withdrawn + else: + num_exiting = min( + len(exiting_node_operators), max_total_list_length // 2 + ) + num_withdrawn = max_total_list_length - num_exiting + + if num_exiting > 0: + description += "\n**Exiting Node Operators**\n" + description += ", ".join( + [ + f"{await el_explorer_url(w3.to_checksum_address(v))} ({c})" + for v, c in exiting_node_operators[:num_exiting] + ] + ) + if remaining_no := exiting_node_operators[num_exiting:]: + num_remaining_valis = sum([c for _, c in remaining_no]) + description += ( + f", and {len(remaining_no)} more ({num_remaining_valis})" + ) + description += "\n" + if num_withdrawn > 0: + description += "\n**Withdrawn Node Operators**\n" + description += ", ".join( + [ + f"{await el_explorer_url(w3.to_checksum_address(v))} ({c})" + for v, c in withdrawn_node_operators[:num_withdrawn] + ] + ) + if remaining_no := withdrawn_node_operators[num_withdrawn:]: + num_remaining_valis = sum([c for _, c in remaining_no]) + description += ( + f", and {len(remaining_no)} more ({num_remaining_valis})" + ) + description += "\n" + + embed.description = description + await interaction.followup.send(embed=embed) + + +async def setup(self): + await self.add_cog(ValidatorStates(self)) diff --git a/rocketwatch/plugins/wall/wall.py b/rocketwatch/plugins/wall/wall.py index 64029d94..007307fe 100644 --- a/rocketwatch/plugins/wall/wall.py +++ b/rocketwatch/plugins/wall/wall.py @@ -1,46 +1,60 @@ import asyncio import logging -from io import BytesIO -from typing import cast, Literal, Optional from collections import OrderedDict +from io import BytesIO +from typing import Literal, cast import aiohttp import numpy as np -from discord import File +from discord import File, Interaction +from discord.app_commands import command, describe from discord.ext import commands -from discord.ext.commands import Context -from discord.ext.commands import hybrid_command -from discord.app_commands import describe -from matplotlib import ( - pyplot as plt, - font_manager as fm, - ticker, - figure -) from eth_typing import ChecksumAddress, HexStr +from matplotlib import figure, ticker +from matplotlib import font_manager as fm +from matplotlib import pyplot as plt +from matplotlib.patches import Rectangle from rocketwatch import RocketWatch -from utils.time_debug import timerun, timerun_async from utils.embeds import Embed -from utils.visibility import is_hidden_weak -from utils.rocketpool import rp from utils.liquidity import ( - Exchange, CEX, DEX, Market, Liquidity, - Binance, Coinbase, GateIO, OKX, Bitget, MEXC, Bybit, CryptoDotCom, - Kraken, Kucoin, Bithumb, BingX, Bitvavo, HTX, BitMart, Bitrue, CoinTR, - BalancerV2, UniswapV3 + CEX, + DEX, + HTX, + MEXC, + OKX, + BalancerV2, + Binance, + BingX, + Bitget, + Bithumb, + BitMart, + Bitrue, + Bitvavo, + Bybit, + Coinbase, + CoinTR, + CryptoDotCom, + Exchange, + GateIO, + Kraken, + Kucoin, + Liquidity, + Market, + UniswapV3, ) -from utils.cfg import cfg +from utils.rocketpool import rp +from utils.time_debug import timerun, timerun_async +from utils.visibility import is_hidden -log = logging.getLogger("wall") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.wall") class Wall(commands.Cog): def __init__(self, bot: RocketWatch): self.bot = bot self.cex: set[CEX] = { - Binance("RPL", ["USDT"]), + Binance("RPL", ["USDT", "USDC"]), Coinbase("RPL", ["USDC"]), GateIO("RPL", ["USDT"]), OKX("RPL", ["USDT"]), @@ -58,21 +72,40 @@ def __init__(self, bot: RocketWatch): Bitrue("RPL", ["USDT"]), CoinTR("RPL", ["USDT"]), } - self.dex: set[DEX] = { - BalancerV2([ - BalancerV2.WeightedPool(HexStr("0x9f9d900462492d4c21e9523ca95a7cd86142f298000200000000000000000462")) - ]), - UniswapV3([ - cast(ChecksumAddress, "0xe42318eA3b998e8355a3Da364EB9D48eC725Eb45"), - cast(ChecksumAddress, "0xcf15aD9bE9d33384B74b94D63D06B4A9Bd82f640") - ]) - } + self.dex: set[DEX] | None = None + + async def _get_dex(self) -> set[DEX]: + if self.dex is None: + self.dex = { + BalancerV2( + [ + await BalancerV2.WeightedPool.create( + HexStr( + "0x9f9d900462492d4c21e9523ca95a7cd86142f298000200000000000000000462" + ) + ) + ] + ), + await UniswapV3.create( + [ + cast( + ChecksumAddress, + "0xe42318eA3b998e8355a3Da364EB9D48eC725Eb45", + ), + cast( + ChecksumAddress, + "0xcf15aD9bE9d33384B74b94D63D06B4A9Bd82f640", + ), + ] + ), + } + return self.dex @staticmethod - def _get_market_depth_and_liquidity( - markets: dict[Market | DEX.LiquidityPool, Liquidity], - x: np.ndarray, - rpl_usd: float + def _get_market_depth_and_liquidity[K]( + markets: dict[K, Liquidity], + x: np.ndarray, + rpl_usd: float, ) -> tuple[np.ndarray, float]: depth = np.zeros_like(x) liquidity = 0 @@ -80,41 +113,57 @@ def _get_market_depth_and_liquidity( for liq in markets.values(): conv = liq.price / rpl_usd depth += np.array(list(map(liq.depth_at, x * conv))) / conv - liquidity += (liq.depth_at(float(x[0] * conv)) + liq.depth_at(float(x[-1] * conv))) / conv + liquidity += ( + liq.depth_at(float(x[0] * conv)) + liq.depth_at(float(x[-1] * conv)) + ) / conv return depth, liquidity @timerun_async - async def _get_cex_data(self, x: np.ndarray, rpl_usd: float) -> OrderedDict[CEX, np.ndarray]: + async def _get_cex_data( + self, x: np.ndarray, rpl_usd: float + ) -> OrderedDict[CEX, np.ndarray]: depth: dict[CEX, np.ndarray] = {} liquidity: dict[CEX, float] = {} async with aiohttp.ClientSession() as session: requests = [cex.get_liquidity(session) for cex in self.cex] - for result in zip(self.cex, await asyncio.gather(*requests, return_exceptions=True)): - if not isinstance(result, Exception): - cex, markets = result - depth[cex], liquidity[cex] = self._get_market_depth_and_liquidity(markets, x, rpl_usd) - else: - log.error(f"Failed to get liquidity data for {cex}") - await self.bot.report_error(result) - - return OrderedDict(sorted(depth.items(), key=lambda e: liquidity[e[0]], reverse=True)) + for result in zip( + self.cex, + await asyncio.gather(*requests, return_exceptions=True), + strict=False, + ): + cex, maybe_markets = result + if not isinstance(maybe_markets, BaseException): + markets: dict[Market, Liquidity] = maybe_markets + depth[cex], liquidity[cex] = self._get_market_depth_and_liquidity( + markets, x, rpl_usd + ) + elif isinstance(maybe_markets, Exception): + await self.bot.report_error(maybe_markets) + + return OrderedDict( + sorted(depth.items(), key=lambda e: liquidity[e[0]], reverse=True) + ) @timerun - def _get_dex_data(self, x: np.ndarray, rpl_usd: float) -> OrderedDict[DEX, np.ndarray]: + async def _get_dex_data( + self, x: np.ndarray, rpl_usd: float + ) -> OrderedDict[DEX, np.ndarray]: depth: dict[DEX, np.ndarray] = {} liquidity: dict[DEX, float] = {} - for dex in self.dex: - if pools := dex.get_liquidity(): - depth[dex], liquidity[dex] = self._get_market_depth_and_liquidity(pools, x, rpl_usd) + for dex in await self._get_dex(): + if pools := await dex.get_liquidity(): + depth[dex], liquidity[dex] = self._get_market_depth_and_liquidity( + pools, x, rpl_usd + ) - return OrderedDict(sorted(depth.items(), key=lambda e: liquidity[e[0]], reverse=True)) + return OrderedDict( + sorted(depth.items(), key=lambda e: liquidity[e[0]], reverse=True) + ) @staticmethod - def _label_exchange_data( - data: OrderedDict[Exchange, np.ndarray], - max_unique: int, - color_other: str + def _label_exchange_data[E: Exchange]( + data: OrderedDict[E, np.ndarray], max_unique: int, color_other: str ) -> list[tuple[np.ndarray, str, str]]: ret = [] for exchange, depth in list(data.items())[:max_unique]: @@ -128,11 +177,11 @@ def _label_exchange_data( @staticmethod def _plot_data( - x: np.ndarray, - rpl_usd: float, - rpl_eth: float, - cex_data: OrderedDict[CEX, np.ndarray], - dex_data: OrderedDict[DEX, np.ndarray], + x: np.ndarray, + rpl_usd: float, + rpl_eth: float, + cex_data: OrderedDict[CEX, np.ndarray], + dex_data: OrderedDict[DEX, np.ndarray], ) -> figure.Figure: fig, ax = plt.subplots(figsize=(10, 5)) @@ -154,15 +203,19 @@ def _plot_data( dex_data_aggr = Wall._label_exchange_data(dex_data, max_unique, "#777777") y_offset = 0.0 - max_label_length: int = np.max([len(t[1]) for t in (cex_data_aggr + dex_data_aggr)]) + max_label_length: int = np.max( + [len(t[1]) for t in (cex_data_aggr + dex_data_aggr)] + ) - def add_data(_data: list[tuple[np.ndarray, str, str]], _name: Optional[str]) -> None: + def add_data( + _data: list[tuple[np.ndarray, str, str]], _name: str | None + ) -> None: labels, handles = [], [] for y_values, label, color in _data: y.append(y_values) - labels.append(f"{label:\u00A0<{max_label_length}}") + labels.append(f"{label:\u00a0<{max_label_length}}") colors.append(color) - handles.append(plt.Rectangle((0, 0), 1, 1, color=color)) + handles.append(Rectangle((0, 0), 1, 1, color=color)) nonlocal y_offset legend = ax.legend( @@ -171,7 +224,7 @@ def add_data(_data: list[tuple[np.ndarray, str, str]], _name: Optional[str]) -> title=_name, loc="upper left", bbox_to_anchor=(0, 1 - y_offset), - prop=fm.FontProperties(family="monospace", size=10) + prop=fm.FontProperties(family="monospace", size=10), ) ax.add_artist(legend) y_offset += 0.025 + 0.055 * (len(_data) + int(_name is not None)) @@ -184,16 +237,14 @@ def add_data(_data: list[tuple[np.ndarray, str, str]], _name: Optional[str]) -> else: add_data(cex_data_aggr, None) - ax.stackplot(x, np.array(y[::-1]), colors=colors[::-1], edgecolor="black", linewidth=0.3) + ax.stackplot( + x, np.array(y[::-1]), colors=colors[::-1], edgecolor="black", linewidth=0.3 + ) ax.axvline(rpl_usd, color="black", linestyle="--", linewidth=1) def get_formatter(base_fmt: str, *, scale=1.0, prefix="", suffix=""): def formatter(_x, _pos) -> str: - levels = [ - (1_000_000_000, "B"), - (1_000_000, "M"), - (1_000, "K") - ] + levels = [(1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")] modifier = "" base_value = _x * scale @@ -203,59 +254,78 @@ def formatter(_x, _pos) -> str: base_value /= m break - return prefix + f"{base_value:{base_fmt}}".rstrip(".") + modifier + suffix + return ( + prefix + f"{base_value:{base_fmt}}".rstrip(".") + modifier + suffix + ) + return ticker.FuncFormatter(formatter) range_size = x[-1] - x[0] x_ticks = ax.get_xticks() - ax.set_xticks([t for t in x_ticks if abs(t - rpl_usd) >= range_size / 20] + [rpl_usd]) + ax.set_xticks( + [t for t in x_ticks if abs(t - rpl_usd) >= range_size / 20] + [rpl_usd] + ) ax.set_xlim((x[0], x[-1])) - ax.xaxis.set_major_formatter(get_formatter(".2f" if (range_size >= 0.1) else ".3f", prefix="$")) + ax.xaxis.set_major_formatter( + get_formatter(".2f" if (range_size >= 0.1) else ".3f", prefix="$") + ) ax.yaxis.set_major_formatter(get_formatter("#.3g", prefix="$")) ax_top = ax.twiny() ax_top.minorticks_on() - ax_top.set_xticks([t for t in x_ticks if abs(t - rpl_usd) >= range_size / 10] + [rpl_usd]) + ax_top.set_xticks( + [t for t in x_ticks if abs(t - rpl_usd) >= range_size / 10] + [rpl_usd] + ) ax_top.set_xlim(ax.get_xlim()) - ax_top.xaxis.set_major_formatter(get_formatter(".5f", prefix="Ξ ", scale=(rpl_eth / rpl_usd))) + ax_top.xaxis.set_major_formatter( + get_formatter(".5f", prefix="Ξ ", scale=(rpl_eth / rpl_usd)) + ) ax_right = ax.twinx() ax_right.minorticks_on() ax_right.set_yticks(ax.get_yticks()) ax_right.set_ylim(ax.get_ylim()) - ax_right.yaxis.set_major_formatter(get_formatter("#.3g", prefix="Ξ ", scale=(rpl_eth / rpl_usd))) + ax_right.yaxis.set_major_formatter( + get_formatter("#.3g", prefix="Ξ ", scale=(rpl_eth / rpl_usd)) + ) return fig - @hybrid_command() + @command() @describe(min_price="lower end of price range in USD") @describe(max_price="upper end of price range in USD") @describe(sources="choose places to pull liquidity data from") async def wall( - self, - ctx: Context, - min_price: float = 0.0, - max_price: float = None, - sources: Literal["All", "CEX", "DEX"] = "All" + self, + interaction: Interaction, + min_price: float = 0.0, + max_price: float | None = None, + sources: Literal["All", "CEX", "DEX"] = "All", ) -> None: """Show the current RPL market depth across exchanges""" - await ctx.defer(ephemeral=is_hidden_weak(ctx)) + await interaction.response.defer(ephemeral=is_hidden(interaction)) embed = Embed(title="RPL Market Depth") async def on_fail() -> None: - embed.set_image(url="https://media1.giphy.com/media/hEc4k5pN17GZq/giphy.gif") - await ctx.send(embed=embed) + embed.set_image( + url="https://media1.giphy.com/media/hEc4k5pN17GZq/giphy.gif" + ) + await interaction.followup.send(embed=embed) return None try: async with aiohttp.ClientSession() as session: # use Binance as USD price oracle - rpl_usd = list((await Binance("RPL", ["USDT"]).get_liquidity(session)).values())[0].price - eth_usd = rp.get_eth_usdc_price() + rpl_usd = next( + iter( + (await Binance("RPL", ["USDT"]).get_liquidity(session)).values() + ) + ).price + eth_usd = await rp.get_eth_usdc_price() rpl_eth = rpl_usd / eth_usd except Exception as e: - await self.bot.report_error(e, ctx) + await self.bot.report_error(e, interaction) return await on_fail() if min_price < 0: @@ -272,17 +342,18 @@ async def on_fail() -> None: x = np.arange(min_price, max_price + step_size, step_size) source_desc = [] - cex_data, dex_data = {}, {} + cex_data: OrderedDict[CEX, np.ndarray] = OrderedDict() + dex_data: OrderedDict[DEX, np.ndarray] = OrderedDict() try: if sources != "CEX": - dex_data = self._get_dex_data(x, rpl_usd) + dex_data = await self._get_dex_data(x, rpl_usd) source_desc.append(f"{len(dex_data)} DEX") if sources != "DEX": cex_data = await self._get_cex_data(x, rpl_usd) source_desc.append(f"{len(cex_data)} CEX") except Exception as e: - await self.bot.report_error(e, ctx) + await self.bot.report_error(e, interaction) return await on_fail() if (not cex_data) and (not dex_data): @@ -295,16 +366,20 @@ async def on_fail() -> None: buffer = BytesIO() fig = self._plot_data(x, rpl_usd, rpl_eth, cex_data, dex_data) fig.savefig(buffer, format="png") + plt.close(fig) buffer.seek(0) embed.set_author(name="🔗 Data from CEX APIs and Mainnet") embed.add_field(name="Current Price", value=f"${rpl_usd:,.2f} | Ξ{rpl_eth:.5f}") - embed.add_field(name="Observed Liquidity", value=f"${liquidity_usd:,.0f} | Ξ{liquidity_eth:,.0f}") + embed.add_field( + name="Observed Liquidity", + value=f"${liquidity_usd:,.0f} | Ξ{liquidity_eth:,.0f}", + ) embed.add_field(name="Sources", value=", ".join(source_desc)) file_name = "wall.png" embed.set_image(url=f"attachment://{file_name}") - await ctx.send(embed=embed, files=[File(buffer, file_name)]) + await interaction.followup.send(embed=embed, files=[File(buffer, file_name)]) return None diff --git a/rocketwatch/requirements.txt b/rocketwatch/requirements.txt deleted file mode 100644 index e2c5b27a..00000000 --- a/rocketwatch/requirements.txt +++ /dev/null @@ -1,51 +0,0 @@ -psutil==5.9.8 -python_i18n==0.3.9 -web3==5.31.4 -humanize==4.6.0 -termplotlib==0.3.9 -cachetools==5.3.3 -bidict==0.22.1 -tinydb==4.7.1 -requests==2.32.4 -uptime==3.0.1 -discord.py==2.5.2 -config==0.5.1 -pytz==2022.7.1 -matplotlib==3.7.1 -scipy==1.11.3 -inflect==7.3.1 -motor==3.1.1 -wordcloud==1.9.4 -web3-multicall==0.0.7 -colorama==0.4.6 -seaborn==0.12.2 -etherscan_labels @ git+https://github.com/InvisibleSymbol/etherscan-labels@7eb617d715a4dda0eabdd858106a526a3abd3394 -cronitor==4.6.0 -circuitbreaker==1.4.0 -retry-async==0.1.4 -checksumdir==1.2.0 -multicall==0.11.0 -dice==3.1.2 -openai==1.10.0 -transformers==4.48.0 -schedule==1.2.2 -suntimes==1.1.2 -icalendar==5.0.13 -regex==2023.8.8 -tiktoken==0.5.2 -anthropic==0.18.1 -HomeAssistant-API==4.2.2.post2 -bs4==0.0.2 -pydantic==2.8.2 -pydantic_core==2.20.1 -pymongo==4.8.0 -graphql_query==1.4.0 -pillow==11.1.0 -aiohttp==3.11.12 -eth-account==0.5.9 -numpy==1.26.4 -beautifulsoup4==4.13.3 -eth-typing==2.2.1 -hexbytes==0.3.1 -eth-utils==1.10.0 -tabulate==0.9.0 diff --git a/rocketwatch/rocketwatch.py b/rocketwatch/rocketwatch.py index abd7f857..a5015ffd 100644 --- a/rocketwatch/rocketwatch.py +++ b/rocketwatch/rocketwatch.py @@ -1,48 +1,36 @@ -import io import logging +import sys import traceback from pathlib import Path -from typing import Optional -from discord import ( - app_commands, - Interaction, - Intents, - Thread, - File, - Object, - User, -) -from discord.abc import GuildChannel, PrivateChannel -from discord.ext import commands -from discord.ext.commands import Bot, Context -from discord.app_commands import CommandTree, AppCommandError +from discord import Guild, Intents, Interaction, Role, Thread, User +from discord.abc import GuildChannel, Messageable, PrivateChannel +from discord.ext.commands import Bot +from pymongo import AsyncMongoClient +from pymongo.asynchronous.database import AsyncDatabase -from utils.cfg import cfg -from utils.retry import retry_async +from utils.command_tree import RWCommandTree +from utils.config import cfg +from utils.file import TextFile +from utils.retry import retry +from utils.rocketpool import rp -log = logging.getLogger("rocketwatch") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.bot") class RocketWatch(Bot): - class RWCommandTree(CommandTree): - async def on_error(self, interaction: Interaction, error: AppCommandError) -> None: - bot: RocketWatch = self.client - ctx = await Context.from_interaction(interaction) - await bot.on_command_error(ctx, error) - def __init__(self, intents: Intents) -> None: - super().__init__(command_prefix=(), tree_cls=self.RWCommandTree, intents=intents) - + super().__init__(command_prefix=(), tree_cls=RWCommandTree, intents=intents) + self.db: AsyncDatabase = AsyncMongoClient(cfg.mongodb.uri).rocketwatch + async def _load_plugins(self): - chain = cfg["rocketpool.chain"] - storage = cfg["rocketpool.manual_addresses.rocketStorage"] + chain = cfg.rocketpool.chain + storage = cfg.rocketpool.manual_addresses["rocketStorage"] log.info(f"Running using storage contract {storage} (Chain: {chain})") log.info("Loading plugins...") - included_modules = set(cfg["modules.include"] or []) - excluded_modules = set(cfg["modules.exclude"] or []) + included_modules = set(cfg.modules.include or []) + excluded_modules = set(cfg.modules.exclude or []) def should_load_plugin(_plugin: str) -> bool: # inclusion takes precedence in case of collision @@ -59,95 +47,113 @@ def should_load_plugin(_plugin: str) -> bool: log.debug(f"Plugin {_plugin} implicitly included") return True - for path in Path("plugins").glob('**/*.py'): + for path in Path("plugins").glob("**/*.py"): plugin_name = path.stem if not should_load_plugin(plugin_name): log.warning(f"Skipping plugin {plugin_name}") continue - log.info(f"Loading plugin \"{plugin_name}\"") + log.info(f'Loading plugin "{plugin_name}"') try: extension_name = f"plugins.{plugin_name}.{plugin_name}" await self.load_extension(extension_name) - except Exception: - log.exception(f"Failed to load plugin \"{plugin_name}\"") + except Exception as e: + log.exception(f'Failed to load plugin "{plugin_name}"') + await self.report_error(e) - log.info('Finished loading plugins') + log.info("Finished loading plugins") async def setup_hook(self) -> None: + await rp.async_init() await self._load_plugins() - + async def sync_commands(self) -> None: log.info("Syncing command tree...") await self.tree.sync() for guild in self.guilds: await self.tree.sync(guild=guild) - + def clear_commands(self) -> None: self.tree.clear_commands(guild=None) for guild in self.guilds: self.tree.clear_commands(guild=guild) + async def on_error(self, event_method: str, /, *args, **kwargs) -> None: + exc = sys.exc_info()[1] + if isinstance(exc, Exception): + log.exception(f"Error in listener {event_method}") + await self.report_error(exc) + else: + log.exception("Ignoring BaseException in error handler") + async def on_ready(self): + assert self.user is not None log.info(f"Logged in as {self.user.name} ({self.user.id})") - commands_enabled = cfg["modules.enable_commands"] + commands_enabled = cfg.modules.enable_commands if not commands_enabled: - log.info("Commands disabled, clearing tree...") + log.info("Commands disabled, clearing local tree...") self.clear_commands() if commands_enabled is None: - log.info("Command sync behavior unspecified, skipping") + log.info("Sync behavior unspecified, skipping") return await self.sync_commands() - async def on_command_error(self, ctx: Context, error: Exception) -> None: - log.error(f"/{ctx.command.name} called by {ctx.author} in #{ctx.channel.name} ({ctx.guild}) failed") - if isinstance(error, commands.errors.MaxConcurrencyReached): - msg = "Someone else is already using this command. Please try again later." - elif isinstance(error, app_commands.errors.CommandOnCooldown): - msg = f"Slow down! You are using this command too fast. Please try again in {error.retry_after:.0f} seconds." - else: - msg = "An unexpected error occurred and has been reported to the developer. Please try again later." - - try: - await self.report_error(error, ctx) - await ctx.send(content=msg, ephemeral=True) - except Exception: - log.exception("Failed to alert user") - - async def get_or_fetch_guild(self, guild_id: int) -> Object: + async def get_or_fetch_guild(self, guild_id: int) -> Guild: return self.get_guild(guild_id) or await self.fetch_guild(guild_id) - async def get_or_fetch_channel(self, channel_id: int) -> GuildChannel | PrivateChannel | Thread: + async def get_or_fetch_channel( + self, channel_id: int + ) -> GuildChannel | PrivateChannel | Thread: return self.get_channel(channel_id) or await self.fetch_channel(channel_id) async def get_or_fetch_user(self, user_id: int) -> User: return self.get_user(user_id) or await self.fetch_user(user_id) - async def report_error(self, exception: Exception, ctx: Optional[Context] = None, *args) -> None: + async def get_or_fetch_role(self, guild_id: int, role_id: int) -> Role: + guild = await self.get_or_fetch_guild(guild_id) + return guild.get_role(role_id) or await guild.fetch_role(role_id) + + async def report_error( + self, exception: Exception, interaction: Interaction | None = None, *args + ) -> None: err_description = f"`{repr(exception)[:150]}`" - + if args: args_fmt = "\n".join(f"args[{i}] = {arg}" for i, arg in enumerate(args)) err_description += f"\n```{args_fmt}```" - - if ctx: + + if interaction: + if interaction.command: + cmd_name = interaction.command.name + else: + cmd_name = getattr(interaction, "data", {}).get("name", "unknown") + cmd_options = ( + interaction.namespace.__dict__ + if interaction.namespace + else (interaction.data.get("options", []) if interaction.data else []) + ) err_description += ( f"\n```" - f"{ctx.command.name = }\n" - f"ctx.command.params = {getattr(ctx.command, 'params')}\n" - f"{ctx.channel = }\n" - f"{ctx.author = }" + f"command = {cmd_name}\n" + f"command.params = {cmd_options}\n" + f"channel = {interaction.channel}\n" + f"user = {interaction.user}" f"```" ) error = getattr(exception, "original", exception) - err_trace = "".join(traceback.format_exception(type(error), error, error.__traceback__)) + err_trace = "".join( + traceback.format_exception(type(error), error, error.__traceback__) + ) log.error(err_trace) try: - channel = await self.get_or_fetch_channel(cfg["discord.channels.errors"]) - file = File(io.StringIO(err_trace), "exception.txt") - await retry_async(tries=5, delay=5)(channel.send)(err_description, file=file) + channel = await self.get_or_fetch_channel(cfg.discord.channels["errors"]) + file = TextFile(err_trace, "exception.txt") + assert isinstance(channel, Messageable), ( + f"Error channel {channel} is not messageable" + ) + await retry(tries=5, delay=5)(channel.send)(err_description, file=file) except Exception: - log.exception("Failed to send message. Max retries reached.") + log.exception("Failed to send message.") diff --git a/rocketwatch/strings.py b/rocketwatch/strings.py index 4c8097f4..0ca2ddbf 100644 --- a/rocketwatch/strings.py +++ b/rocketwatch/strings.py @@ -1,7 +1,7 @@ import i18n -i18n.load_path.append('./strings/') -i18n.set('skip_locale_root_data', True) -i18n.set('error_on_missing_translation', False) -i18n.set('file_format', 'json') +i18n.load_path.append("./strings/") +i18n.set("skip_locale_root_data", True) +i18n.set("error_on_missing_translation", False) +i18n.set("file_format", "json") _ = i18n.t diff --git a/rocketwatch/strings/addresses.en.json b/rocketwatch/strings/addresses.en.json index ce854020..f5946ded 100644 --- a/rocketwatch/strings/addresses.en.json +++ b/rocketwatch/strings/addresses.en.json @@ -4,14 +4,14 @@ "0xF0138d2e4037957D7b37De312a16a88A7f83A32a": "🛠️Invis", "0x75Cf8e1F8F4fbF4C7BB216E450BCff5F51Ab3E5A": "🛠️Invis", "0x701F4dcEAD1049FA01F321d49F6dca525cF4A5A5": "MEEK", - "0x17Fa597cEc16Ab63A7ca00Fb351eb4B29Ffa6f46": "thomas", + "0x17Fa597cEc16Ab63A7ca00Fb351eb4B29Ffa6f46": "Thomas", "0x78072BA5f77d01B3f5B1098df73176933da02A7A": "markobarko", "0x5e624FAEDc7AA381b574c3C2fF1731677Dd2ee1d": "jamescarnley", "0xb8ed9ea221bf33d37360A76DDD52bA7b1E66AA5C": "Lovinall #1", "0xca317A4ecCbe0Dd5832dE2A7407e3c03F88b2CdD": "Lovinall #2", "0x64627611655C8CdcaFaE7607589b4483a1578f4A": "Darcius", "0x33043c521E9c3e80E0c05A2c25f2e894FefC0328": "cjrtp", - "0xc942B5aA63A3410a13358a7a3aEdF33d9e9D3AC3": "langers", + "0xc942B5aA63A3410a13358a7a3aEdF33d9e9D3AC3": "Langers", "0x8630eE161EF00dE8E70F19Bf5fE5a06046898990": "Marceau.eth #2", "0x01A2a10ed806d4e65Ad92c2c6b10bC4D5F37001e": "onethousand.eth", "0x75C8F18e401113167A43bB21556cc132BF8C7ca9": "onethousand.eth", @@ -86,5 +86,9 @@ "0x3666f603Cc164936C1b87e207F36BEBa4AC5f18a": "Hop: L1 USDC bridge", "0x076732017b95A98A618BC9eEc3523A0058366807": "Cakepie Reward Distributor", "0xfEb352930cA196a80B708CDD5dcb4eCA94805daB": "Paladin V2.1 QuestBoard veBAL", - "0x2A906f92B0378Bb19a3619E2751b1e0b8cab6B29": "Constellation Supernode" + "0x2A906f92B0378Bb19a3619E2751b1e0b8cab6B29": "Constellation Supernode", + "0xFD857D3cFcb942039388FBd44c18163f91552b35": "Dev Wallet", + "0x89Af09B5fA88B8989BA5a8960982cCCCA0BEa6F0": "Core Team", + "0x9Ca1d6E730Eb9fbfD45c9FF5F0AC4E3d172d8F4d": "RockSolid Vault", + "0xc23b28337896ab92d7e8ed0303cec0609a58143b": "elpresidank" } diff --git a/rocketwatch/strings/embeds.en.json b/rocketwatch/strings/embeds.en.json index 25d8b57e..ea772afd 100644 --- a/rocketwatch/strings/embeds.en.json +++ b/rocketwatch/strings/embeds.en.json @@ -72,8 +72,8 @@ }, "pool_deposit_event": { "title": ":rocket: Pool Deposit", - "description": "**%{amount} ETH** deposited into the deposit pool!", - "description_small": ":rocket: %{fancy_from} deposited **%{amount} ETH** into the deposit pool!" + "description": "**%{amount} ETH** deposited for rETH!", + "description_small": ":rocket: %{fancy_from} deposited **%{amount} ETH** for rETH!" }, "odao_rewards_snapshot_event": { "title": ":camera_with_flash: Reward Snapshot Published", @@ -119,6 +119,22 @@ "title": ":crystal_ball: oDAO Contract Upgrade", "description": "The contract `%{name}` has been upgraded to %{contractAddress}!" }, + "odao_upgrade_pending_event": { + "title": ":hourglass: Contract Upgrade Pending", + "description": "The upgrade process for `%{contractName}` has been initiated.\nVeto window ends %{vetoDeadline}." + }, + "sdao_upgrade_vetoed_event": { + "title": ":no_entry: Contract Upgrade Vetoed", + "description": "Upgrade #%{upgradeProposalID} for `%{contractName}` has been vetoed by the security council!" + }, + "odao_contract_added_event": { + "title": ":page_facing_up: Contract Added", + "description": "New contract `%{contractName}` added at %{newAddress}." + }, + "odao_contract_upgraded_event": { + "title": ":page_facing_up: Contract Upgraded", + "description": "`%{contractName}` has been upgraded to %{newAddress}." + }, "odao_member_invite": { "title": ":crystal_ball: oDAO Invite", "description": "**%{id}** (%{nodeAddress}) has been invited to join the oDAO!" @@ -175,6 +191,11 @@ "title": ":chart_with_upwards_trend: RPL Inflation Occurred", "description": "%{value} new RPL minted! The new total supply is %{total_supply} RPL." }, + "rpl_migration_event": { + "title": ":arrows_counterclockwise: RPL Migration", + "description": "%{from} migrated **%{amount} RPL v1** to the new token contract!", + "description_small": ":arrows_counterclockwise: %{from} migrated **%{amount} RPL**!" + }, "milestone_rpl_stake": { "title": ":tada: Milestone Reached", "description": "%{result_value} RPL has been staked by node operators!" @@ -193,12 +214,16 @@ }, "milestone_registered_nodes": { "title": ":tada: Milestone Reached", - "description": "%{result_value} Nodes have been registered!" + "description": "%{result_value} nodes have been registered!" }, "milestone_rpl_swapped": { "title": ":tada: Milestone Reached", "description": "%{result_value}% of all RPL has been exchanged for the new version!" }, + "milestone_rocksolid_tvl": { + "title": ":tada: Milestone Reached", + "description": "%{result_value} rETH deposited into the RockSolid vault!" + }, "bootstrap_odao_member": { "title": ":satellite_orbital: oDAO Bootstrap Mode: Member Added", "description": "%{nodeAddress} added as a new oDAO member!" @@ -314,7 +339,7 @@ }, "pdao_spend_treasury": { "title": ":bank: DAO Treasury Spend", - "description": "**%{amount} RPL** from treasury sent to %{to}!" + "description": "**%{amount} RPL** from treasury sent to %{recipientAddress}!" }, "pdao_spend_treasury_recurring_new": { "title": ":bank: DAO Treasury: New Recurring Spend", @@ -334,7 +359,7 @@ }, "minipool_dissolve_event": { "title": ":rotating_light: Minipool Dissolved", - "description": "Minipool %{minipool} failed to stake its assigned ETH and has been dissolved!" + "description": "Minipool %{minipool} owned by operator %{operator} failed to stake its assigned ETH and has been dissolved!" }, "vacant_minipool_scrub_event": { "title": ":rotating_light: Vacant Minipool Scrubbed", @@ -357,12 +382,10 @@ "description": "Minipool %{minipoolAddress} has had its Penalty increased to %{penalty_perc}%!" }, "node_smoothing_pool_joined": { - "title": ":cup_with_straw: Node Operator Joined Smoothing Pool", - "description": "Node operator %{node} joined the smoothing pool with their %{minipoolCount} minipools!" + "description_small": ":cup_with_straw: %{node} joined the smoothing pool with their %{validatorCount} validators!" }, "node_smoothing_pool_left": { - "title": ":leaves: Node Operator Left Smoothing Pool", - "description": "Node operator %{node} has left the smoothing pool with their %{minipoolCount} minipools!" + "description_small": ":cup_with_straw: %{node} has left the smoothing pool with their %{validatorCount} validators!" }, "auction_lot_create_event": { "title": ":scales: Lot Created", @@ -410,23 +433,51 @@ }, "mev_proposal_event": { "title": ":moneybag: Large Minipool Proposal", - "description": "Minipool %{minipool} has proposed a block worth **%{reward_amount} ETH**!" + "description": "Validator %{validator} has proposed a block worth **%{reward_amount} ETH**!" }, "mev_proposal_smoothie_event": { "title": ":cup_with_straw: Large Smoothing Pool Proposal", - "description": "Minipool %{minipool} has proposed a block worth **%{reward_amount} ETH**!" + "description": "Validator %{validator} has proposed a block worth **%{reward_amount} ETH**!" }, "minipool_vacancy_prepared_event": { "title": ":link: Solo Migration Initiated", "description": "Migration of solo validator %{pubkey} to minipool %{minipool} with a bond of **%{bondAmount} ETH** was initiated!" }, "minipool_failed_deposit": { - "title": ":fire: Failed Minipool Deposit", - "description": ":fire_engine: %{node} burned **%{burnedValue} ETH** trying to create a minipool! :fire_engine:" + "title": ":fire: Failed Validator Deposit", + "description": ":fire_engine: %{node} burned **%{burnedValue} ETH** trying to create a validator! :fire_engine:" + }, + "validator_slash_event": { + "title": ":rotating_light: Validator Slashed", + "description": "Validtor %{validator} has been slashed by %{slasher}" + }, + "validator_deposit_event": { + "description_small": ":construction_site: %{from} created a validator with a **%{amount} ETH** bond!" }, - "minipool_slash_event": { - "title": ":rotating_light: Minipool Slashed", - "description": "Minipool %{minipool} has been slashed by %{slasher}" + "validator_multi_deposit_event": { + "title": ":construction_site: Multi Validator Deposit", + "description": "**%{numberOfValidators} validators** created with a total bond of **%{amount} ETH**!", + "description_small": ":construction_site: %{from} created **%{numberOfValidators} validators** with a **%{amount} ETH** bond!" + }, + "megapool_validator_assigned_event": { + "description_small": ":handshake: Validator %{validatorId} of node %{node} has been assigned funds from the deposit pool!" + }, + "megapool_validator_exiting_event": { + "description_small": ":octagonal_sign: Validator %{validatorId} of node %{node} has started exiting!" + }, + "megapool_validator_exited_event": { + "description_small": ":leaves: Validator %{validatorId} of node %{node} has exited!" + }, + "megapool_validator_dissolve_event": { + "title": ":rotating_light: Validator Dissolved", + "description": ":leaves: Validator %{validatorId} of node %{node} has been dissolved!" + }, + "megapool_penalty_event": { + "title": ":police_car: Megapool Penalty Applied", + "description": "Node %{node} has been penalized for **%{amount} ETH**!" + }, + "validator_queue_exited_event": { + "description_small": ":leaves: %{nodeAddress} has removed a validator from the queue!" }, "otc_swap_event": { "title": ":currency_exchange: OTC Swap", @@ -472,25 +523,29 @@ "title": ":tada: Houston Hotfix Upgrade Complete!", "description": "" }, + "saturn_one_upgrade_triggered": { + "title": ":ringed_planet: Saturn 1 Upgrade Complete!", + "description": "" + }, "unsteth_withdrawal_requested_event": { "title": ":money_with_wings: Large stETH Withdrawal Requested", "description": "%{owner} has requested a withdrawal of **%{amountOfStETH} stETH**!" }, - "cow_order_buy_rpl_found": { - "title": ":cow: BUY Order Found", - "description": "%{cow_owner} has placed a buy order for %{ourAmount} RPL!\n Exchanging %{otherAmount} %{otherToken} for %{ourAmount} RPL (%{ratioAmount} RPL/%{otherToken})\nExpires: %{deadline}" + "cow_order_buy_rpl": { + "title": ":cow: RPL Buy", + "description": "%{cow_owner} bought **%{ourAmount} RPL** for %{otherAmount} %{otherToken}!" }, - "cow_order_buy_reth_found": { - "title": ":cow: rETH Order Found", - "description": "%{cow_owner} has placed a buy order for %{ourAmount} rETH!\n Exchanging %{otherAmount} %{otherToken} for %{ourAmount} rETH (%{ratioAmount} rETH/%{otherToken})\nExpires: %{deadline}" + "cow_order_buy_reth": { + "title": ":cow: rETH Buy", + "description": "%{cow_owner} bought **%{ourAmount} rETH** for %{otherAmount} %{otherToken}!" }, - "cow_order_sell_rpl_found": { - "title": ":cow: SELL Order Found", - "description": "%{cow_owner} has placed a sell order for %{ourAmount} RPL!\n Exchanging %{ourAmount} RPL for %{otherAmount} %{otherToken} (%{ratioAmount} RPL/%{otherToken})\nExpires: %{deadline}" + "cow_order_sell_rpl": { + "title": ":cow: RPL Sell", + "description": "%{cow_owner} sold **%{ourAmount} RPL** for %{otherAmount} %{otherToken}!" }, - "cow_order_sell_reth_found": { - "title": ":cow: rETH Order Found", - "description": "%{cow_owner} has placed a sell order for %{ourAmount} rETH!\n Exchanging %{ourAmount} rETH for %{otherAmount} %{otherToken} (%{ratioAmount} rETH/%{otherToken})\nExpires: %{deadline}" + "cow_order_sell_reth": { + "title": ":cow: rETH Sell", + "description": "%{cow_owner} sold **%{ourAmount} rETH** for %{otherAmount} %{otherToken}!" }, "finality_delay_event": { "title": ":warning: Finality Delay On Beacon Chain", @@ -572,5 +627,15 @@ "title": ":money_mouth: Large Exit Arbitrage", "description": "%{caller} earned **%{profit} ETH** with a %{amount} ETH flash loan!", "description_small": ":money_mouth: %{caller} earned **%{profit} ETH** from an exit arbitrage!" + }, + "rocksolid_deposit_event": { + "title": "<:rocksolid:1425091714267480158> RockSolid rETH Deposit", + "description": "**%{assets} rETH** deposited into the RockSolid vault!", + "description_small": "<:rocksolid:1425091714267480158> %{sender} deposited **%{assets} rETH** into the RockSolid vault!" + }, + "rocksolid_withdrawal_event": { + "title": "<:rocksolid:1425091714267480158> RockSolid rETH Withdrawal", + "description": "New withdrawal request for **%{assets} rETH** from the RockSolid vault!", + "description_small": "<:rocksolid:1425091714267480158> %{sender} requested a withdrawal for **%{assets} rETH** from the RockSolid vault!" } } diff --git a/rocketwatch/utils/block_time.py b/rocketwatch/utils/block_time.py index 21d9aaeb..6e78c38b 100644 --- a/rocketwatch/utils/block_time.py +++ b/rocketwatch/utils/block_time.py @@ -1,32 +1,34 @@ -import math import logging -from functools import cache +import math + +from aiocache import cached +from eth_typing import BlockNumber +from web3.types import BlockData -from utils.cfg import cfg from utils.shared_w3 import w3 -log = logging.getLogger("block_time") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.block_time") -@cache -def block_to_ts(block_number: int) -> int: - return w3.eth.get_block(block_number).timestamp +@cached() +async def block_to_ts(block_number: int) -> int: + block: BlockData = await w3.eth.get_block(block_number) + return block.get("timestamp", 0) -@cache -def ts_to_block(target_ts: int) -> int: + +async def ts_to_block(target_ts: int) -> BlockNumber: log.debug(f"Looking for block at timestamp {target_ts}") - if target_ts < block_to_ts(1): + if target_ts < await block_to_ts(1): # genesis block doesn't have a timestamp - return 0 + return BlockNumber(0) lo = 1 - hi = w3.eth.block_number - 1 - + hi = await w3.eth.get_block_number() - 1 + # simple binary search over block numbers while lo < hi: mid = math.ceil((lo + hi) / 2) - ts = block_to_ts(mid) + ts = await block_to_ts(mid) if ts < target_ts: lo = mid @@ -34,12 +36,14 @@ def ts_to_block(target_ts: int) -> int: hi = mid - 1 elif ts == target_ts: log.debug(f"Exact match: block {mid} @ {ts}") - return mid + return BlockNumber(mid) # l == r, highest block number below target block = hi - if abs(block_to_ts(block + 1) - target_ts) < abs(block_to_ts(block) - target_ts): + if abs(await block_to_ts(block + 1) - target_ts) < abs( + await block_to_ts(block) - target_ts + ): block += 1 - - log.debug(f"Closest match: block {block} @ {block_to_ts(block)}") - return block + + log.debug(f"Closest match: block {block} @ {await block_to_ts(block)}") + return BlockNumber(block) diff --git a/rocketwatch/utils/cached_ens.py b/rocketwatch/utils/cached_ens.py index 48b26aa1..f9be45df 100644 --- a/rocketwatch/utils/cached_ens.py +++ b/rocketwatch/utils/cached_ens.py @@ -1,27 +1,27 @@ import logging -from typing import Optional -from cachetools.func import ttl_cache -from ens import ENS +from aiocache import cached +from ens import AsyncENS from eth_typing import ChecksumAddress -from utils.cfg import cfg -from utils.shared_w3 import mainnet_w3 +from utils.shared_w3 import w3_mainnet -log = logging.getLogger("cached_ens") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.cached_ens") class CachedEns: def __init__(self): - self.ens = ENS.from_web3(mainnet_w3) + self.ens = AsyncENS.from_web3(w3_mainnet) - @ttl_cache(ttl=300) - def get_name(self, address: ChecksumAddress) -> Optional[str]: + @cached(key_builder=lambda _, _self, address: address) + async def get_name(self, address: ChecksumAddress) -> str | None: log.debug(f"Retrieving ENS name for {address}") - return self.ens.name(address) + return await self.ens.name(address) - @ttl_cache(ttl=300) - def resolve_name(self, name: str) -> Optional[ChecksumAddress]: + @cached(key_builder=lambda _, _self, name: name) + async def resolve_name(self, name: str) -> ChecksumAddress | None: log.debug(f"Resolving ENS name {name}") - return self.ens.address(name) + return await self.ens.address(name) + + +ens = CachedEns() diff --git a/rocketwatch/utils/cfg.py b/rocketwatch/utils/cfg.py deleted file mode 100644 index e94d73fa..00000000 --- a/rocketwatch/utils/cfg.py +++ /dev/null @@ -1,3 +0,0 @@ -import config - -cfg = config.Config("main.cfg") diff --git a/rocketwatch/utils/command_tree.py b/rocketwatch/utils/command_tree.py new file mode 100644 index 00000000..305ee6b0 --- /dev/null +++ b/rocketwatch/utils/command_tree.py @@ -0,0 +1,141 @@ +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from discord import Interaction +from discord.app_commands import AppCommandError, CommandTree +from discord.app_commands.errors import ( + BotMissingPermissions, + CheckFailure, + CommandOnCooldown, + MissingPermissions, + NoPrivateMessage, + TransformerError, +) + +from utils.config import cfg + +if TYPE_CHECKING: + from rocketwatch.rocketwatch import RocketWatch + +log = logging.getLogger("rocketwatch.command_tree") + + +def _channel_name(interaction: Interaction) -> str: + return getattr(interaction.channel, "name", None) or "DM" + + +class RWCommandTree(CommandTree["RocketWatch"]): + async def _call(self, interaction: Interaction["RocketWatch"]) -> None: + if not cfg.modules.enable_commands: + return + + cmd_name = interaction.command.name if interaction.command else "unknown" + timestamp = datetime.now(UTC) + + channel_name = _channel_name(interaction) + + log.info( + f"/{cmd_name} triggered by {interaction.user} in #{channel_name} ({interaction.guild})" + ) + try: + await self.client.db.command_metrics.insert_one( + { + "_id": interaction.id, + "command": cmd_name, + "options": interaction.data.get("options", []) + if interaction.data + else [], + "user": { + "id": interaction.user.id, + "name": interaction.user.name, + }, + "guild": { + "id": interaction.guild.id, + "name": interaction.guild.name, + } + if interaction.guild + else None, + "channel": { + "id": interaction.channel.id, + "name": channel_name, + } + if interaction.channel + else None, + "timestamp": timestamp, + "status": "pending", + } + ) + except Exception as e: + log.error(f"Failed to insert command into database: {e}") + await self.client.report_error(e) + + try: + await super()._call(interaction) + except Exception as error: + log.info( + f"/{cmd_name} called by {interaction.user} in #{channel_name} ({interaction.guild}) failed" + ) + try: + await self.client.db.command_metrics.update_one( + {"_id": interaction.id}, + { + "$set": { + "status": "error", + "took": (datetime.now(UTC) - timestamp).total_seconds(), + "error": str(error), + } + }, + ) + except Exception as e: + log.exception("Failed to update command status to error") + await self.client.report_error(e) + raise + + log.info( + f"/{cmd_name} called by {interaction.user} in" + f" #{channel_name} ({interaction.guild}) completed successfully" + ) + try: + await self.client.db.command_metrics.update_one( + {"_id": interaction.id}, + { + "$set": { + "status": "completed", + "took": (datetime.now(UTC) - timestamp).total_seconds(), + } + }, + ) + except Exception as e: + log.error(f"Failed to update command status to completed: {e}") + await self.client.report_error(e) + + async def on_error( + self, interaction: Interaction["RocketWatch"], error: AppCommandError + ) -> None: + cmd_name = interaction.command.name if interaction.command else "unknown" + channel_name = _channel_name(interaction) + log.error( + f"/{cmd_name} called by {interaction.user} in #{channel_name} ({interaction.guild}) failed" + ) + + if isinstance(error, CommandOnCooldown): + msg = f"Slow down! You are using this command too fast. Please try again in {error.retry_after:.0f} seconds." + elif isinstance(error, MissingPermissions): + msg = f"You don't have the required permissions to use this command. Missing: {', '.join(error.missing_permissions)}" + elif isinstance(error, BotMissingPermissions): + msg = f"I'm missing the required permissions to run this command. Missing: {', '.join(error.missing_permissions)}" + elif isinstance(error, NoPrivateMessage): + msg = "This command can only be used in a server, not in DMs." + elif isinstance(error, CheckFailure): + msg = "You don't meet the requirements to use this command." + elif isinstance(error, TransformerError): + msg = f"Failed to process the value for `{error.value}`. Please check your input and try again." + else: + msg = "An unexpected error occurred and has been reported to the developer. Please try again later." + + try: + await self.client.report_error(error, interaction) + await interaction.followup.send(content=msg, ephemeral=True) + except Exception: + log.exception("Failed to alert user") diff --git a/rocketwatch/utils/config.py b/rocketwatch/utils/config.py new file mode 100644 index 00000000..1246a859 --- /dev/null +++ b/rocketwatch/utils/config.py @@ -0,0 +1,117 @@ +import tomllib + +from pydantic import BaseModel + + +class DiscordOwner(BaseModel): + user_id: int + server_id: int + + +class DiscordConfig(BaseModel): + secret: str + owner: DiscordOwner + channels: dict[str, int] + + +class ExecutionLayerEndpoint(BaseModel): + current: str + mainnet: str + archive: str | None = None + + +class ExecutionLayerConfig(BaseModel): + explorer: str + endpoint: ExecutionLayerEndpoint + etherscan_secret: str + + +class ConsensusLayerConfig(BaseModel): + explorer: str + endpoint: str + beaconcha_secret: str + + +class MongoDBConfig(BaseModel): + uri: str + + +class RocketPoolSupport(BaseModel): + user_ids: list[int] + role_ids: list[int] + server_id: int + channel_id: int + moderator_id: int + + +class DmWarningConfig(BaseModel): + channels: list[int] + + +class RocketPoolConfig(BaseModel): + chain: str = "mainnet" + manual_addresses: dict[str, str] + dao_multisigs: list[str] + support: RocketPoolSupport + dm_warning: DmWarningConfig + + +class ModulesConfig(BaseModel): + include: list[str] = [] + exclude: list[str] = [] + enable_commands: bool | None = None + + +class StatusMessageConfig(BaseModel): + plugin: str + cooldown: int + fields: list[dict[str, str]] = [] + + +class EventsConfig(BaseModel): + lookback_distance: int + genesis: int + block_batch_size: int + status_message: dict[str, StatusMessageConfig] = {} + + +class SecretsConfig(BaseModel): + wakatime: str = "" + cronitor: str = "" + + +class OtherConfig(BaseModel): + mev_hashes: list[str] = [] + secrets: SecretsConfig = SecretsConfig() + + +class Config(BaseModel): + log_level: str = "DEBUG" + discord: DiscordConfig + execution_layer: ExecutionLayerConfig + consensus_layer: ConsensusLayerConfig + mongodb: MongoDBConfig + rocketpool: RocketPoolConfig + modules: ModulesConfig = ModulesConfig() + events: EventsConfig + other: OtherConfig = OtherConfig() + + +class _ConfigProxy: + _instance: Config | None = None + + def __init__(self, path: str = "config.toml") -> None: + self.__path = path + + def __load_config(self) -> None: + with open(self.__path, "rb") as f: + data = tomllib.load(f) + cfg._instance = Config(**data) + + def __getattr__(self, name: str): + if self._instance is None: + self.__load_config() + return getattr(self._instance, name) + + +cfg = _ConfigProxy() diff --git a/rocketwatch/utils/dao.py b/rocketwatch/utils/dao.py index 7c550ac0..61e3c490 100644 --- a/rocketwatch/utils/dao.py +++ b/rocketwatch/utils/dao.py @@ -1,27 +1,37 @@ -import math import logging - -from enum import IntEnum +import math from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Optional, Literal, cast +from enum import IntEnum +from typing import Literal, cast import termplotlib as tpl from eth_typing import ChecksumAddress from utils import solidity -from utils.cfg import cfg from utils.rocketpool import rp -log = logging.getLogger("dao") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.dao") class DAO(ABC): def __init__(self, contract_name: str, proposal_contract_name: str): self.contract_name = contract_name - self.contract = rp.get_contract_by_name(contract_name) - self.proposal_contract = rp.get_contract_by_name(proposal_contract_name) + self._proposal_contract_name = proposal_contract_name + self._contract = None + self._proposal_contract = None + + async def _get_contract(self): + if self._contract is None: + self._contract = await rp.get_contract_by_name(self.contract_name) + return self._contract + + async def _get_proposal_contract(self): + if self._proposal_contract is None: + self._proposal_contract = await rp.get_contract_by_name( + self._proposal_contract_name + ) + return self._proposal_contract @dataclass(frozen=True, slots=True) class Proposal(ABC): @@ -31,9 +41,8 @@ class Proposal(ABC): payload: bytes created: int - @staticmethod @abstractmethod - def fetch_proposal(self, proposal_id: int) -> Optional[Proposal]: + async def fetch_proposal(self, proposal_id: int) -> Proposal: pass @abstractmethod @@ -44,16 +53,16 @@ def _build_vote_graph(self, proposal: Proposal) -> str: def sanitize(message: str) -> str: max_length = 150 if len(message) > max_length: - message = message[:(max_length - 1)] + "…" + message = message[: (max_length - 1)] + "…" return message - def build_proposal_body( - self, - proposal: Proposal, - *, - include_proposer=True, - include_payload=True, - include_votes=True + async def build_proposal_body( + self, + proposal: Proposal, + *, + include_proposer=True, + include_payload=True, + include_votes=True, ) -> str: body_repr = f"Description:\n{self.sanitize(proposal.message)}" @@ -62,15 +71,18 @@ def build_proposal_body( if include_payload: try: - decoded = self.contract.decode_function_input(proposal.payload) - function_name = decoded[0].function_identifier + contract = await self._get_contract() + decoded = contract.decode_function_input(proposal.payload) + function_name = decoded[0].abi_element_identifier args = [f" {arg} = {value}" for arg, value in decoded[1].items()] payload_str = f"{function_name}(\n" + "\n".join(args) + "\n)" body_repr += f"\n\nPayload:\n{payload_str}" except Exception: # if this goes wrong, just use the raw payload log.exception("Failed to decode proposal payload") - body_repr += f"\n\nRaw Payload (failed to decode):\n{proposal.payload.hex()}" + body_repr += ( + f"\n\nRaw Payload (failed to decode):\n{proposal.payload.hex()}" + ) if include_votes: body_repr += f"\n\nVotes:\n{self._build_vote_graph(proposal)}" @@ -79,7 +91,12 @@ def build_proposal_body( class DefaultDAO(DAO): - def __init__(self, contract_name: Literal["rocketDAONodeTrustedProposals", "rocketDAOSecurityProposals"]): + def __init__( + self, + contract_name: Literal[ + "rocketDAONodeTrustedProposals", "rocketDAOSecurityProposals" + ], + ): if contract_name == "rocketDAONodeTrustedProposals": self.display_name = "oDAO" elif contract_name == "rocketDAOSecurityProposals": @@ -106,62 +123,81 @@ class Proposal(DAO.Proposal): votes_against: int votes_required: int - def get_proposals_by_state(self) -> dict[ProposalState, list[int]]: - num_proposals = self.proposal_contract.functions.getTotal().call() - proposal_dao_names = [ - res.results[0] for res in rp.multicall.aggregate([ - self.proposal_contract.functions.getDAO(proposal_id) for proposal_id in range(1, num_proposals + 1) - ]).results - ] + async def get_proposal_ids_by_state(self) -> dict[ProposalState, list[int]]: + proposal_contract = await self._get_proposal_contract() + num_proposals = await proposal_contract.functions.getTotal().call() + proposal_dao_names = await rp.multicall( + [ + proposal_contract.functions.getDAO(proposal_id) + for proposal_id in range(1, num_proposals + 1) + ] + ) - relevant_proposals = [(i+1) for (i, dao_name) in enumerate(proposal_dao_names) if (dao_name == self.contract_name)] - proposal_states = [ - res.results[0] for res in rp.multicall.aggregate([ - self.proposal_contract.functions.getState(proposal_id) for proposal_id in relevant_proposals - ]).results + relevant_proposals = [ + (i + 1) + for (i, dao_name) in enumerate(proposal_dao_names) + if (dao_name == self.contract_name) ] + proposal_states = await rp.multicall( + [ + proposal_contract.functions.getState(proposal_id) + for proposal_id in relevant_proposals + ] + ) - proposals = {state: [] for state in DefaultDAO.ProposalState} - for proposal_id, state in zip(relevant_proposals, proposal_states): + proposals: dict[DefaultDAO.ProposalState, list[int]] = { + state: [] for state in DefaultDAO.ProposalState + } + for proposal_id, state in zip( + relevant_proposals, proposal_states, strict=False + ): proposals[state].append(proposal_id) return proposals - def fetch_proposal(self, proposal_id: int) -> Optional[Proposal]: - num_proposals = self.proposal_contract.functions.getTotal().call() - if not (1 <= proposal_id <= num_proposals): - return None - - # map results of functions calls to function name - multicall: dict[str, str | bytes | int] = { - res.function_name: res.results[0] for res in rp.multicall.aggregate([ - self.proposal_contract.functions.getProposer(proposal_id), - self.proposal_contract.functions.getMessage(proposal_id), - self.proposal_contract.functions.getPayload(proposal_id), - self.proposal_contract.functions.getCreated(proposal_id), - self.proposal_contract.functions.getStart(proposal_id), - self.proposal_contract.functions.getEnd(proposal_id), - self.proposal_contract.functions.getExpires(proposal_id), - self.proposal_contract.functions.getVotesFor(proposal_id), - self.proposal_contract.functions.getVotesAgainst(proposal_id), - self.proposal_contract.functions.getVotesRequired(proposal_id) - ]).results - } + async def fetch_proposal(self, proposal_id: int) -> Proposal: + proposal_contract = await self._get_proposal_contract() + ( + proposer, + message, + payload, + created, + start, + end, + expires, + votes_for_raw, + votes_against_raw, + votes_required_raw, + ) = await rp.multicall( + [ + proposal_contract.functions.getProposer(proposal_id), + proposal_contract.functions.getMessage(proposal_id), + proposal_contract.functions.getPayload(proposal_id), + proposal_contract.functions.getCreated(proposal_id), + proposal_contract.functions.getStart(proposal_id), + proposal_contract.functions.getEnd(proposal_id), + proposal_contract.functions.getExpires(proposal_id), + proposal_contract.functions.getVotesFor(proposal_id), + proposal_contract.functions.getVotesAgainst(proposal_id), + proposal_contract.functions.getVotesRequired(proposal_id), + ] + ) return DefaultDAO.Proposal( id=proposal_id, - proposer=cast(ChecksumAddress, multicall["getProposer"]), - message=multicall["getMessage"], - payload=multicall["getPayload"], - created=multicall["getCreated"], - start=multicall["getStart"], - end=multicall["getEnd"], - expires=multicall["getExpires"], - votes_for=solidity.to_int(multicall["getVotesFor"]), - votes_against=solidity.to_int(multicall["getVotesAgainst"]), - votes_required=solidity.to_float(multicall["getVotesRequired"]) + proposer=cast(ChecksumAddress, proposer), + message=message, + payload=payload, + created=created, + start=start, + end=end, + expires=expires, + votes_for=solidity.to_int(votes_for_raw), + votes_against=solidity.to_int(votes_against_raw), + votes_required=solidity.to_float(votes_required_raw), ) - def _build_vote_graph(self, proposal: Proposal) -> str: + def _build_vote_graph(self, proposal: DAO.Proposal) -> str: + assert isinstance(proposal, DefaultDAO.Proposal) votes_for = proposal.votes_for votes_against = proposal.votes_against votes_required = math.ceil(proposal.votes_required) @@ -170,24 +206,27 @@ def _build_vote_graph(self, proposal: Proposal) -> str: graph.barh( [votes_for, votes_against, max([votes_for, votes_against, votes_required])], ["For", "Against", ""], - max_width=12 + max_width=12, ) graph_bars = graph.get_string().split("\n") quorum_perc = max(votes_for, votes_against) / votes_required return ( - f"{graph_bars[0] : <{len(graph_bars[2])}}{'▏' if votes_for >= votes_against else ''}\n" - f"{graph_bars[1] : <{len(graph_bars[2])}}{'▏' if votes_for <= votes_against else ''}\n" + f"{graph_bars[0]: <{len(graph_bars[2])}}{'▏' if votes_for >= votes_against else ''}\n" + f"{graph_bars[1]: <{len(graph_bars[2])}}{'▏' if votes_for <= votes_against else ''}\n" f"Quorum: {quorum_perc:.0%}{' ✔' if (quorum_perc >= 1) else ''}" ) + class OracleDAO(DefaultDAO): def __init__(self): super().__init__("rocketDAONodeTrustedProposals") + class SecurityCouncil(DefaultDAO): def __init__(self): super().__init__("rocketDAOSecurityProposals") + class ProtocolDAO(DAO): def __init__(self): super().__init__("rocketDAOProtocolProposals", "rocketDAOProtocolProposal") @@ -221,80 +260,98 @@ class Proposal(DAO.Proposal): def votes_total(self): return self.votes_for + self.votes_against + self.votes_abstain - def get_proposals_by_state(self) -> dict[ProposalState, list[int]]: - num_proposals = self.proposal_contract.functions.getTotal().call() - proposal_states = [ - res.results[0] for res in rp.multicall.aggregate([ - self.proposal_contract.functions.getState(proposal_id) for proposal_id in range(1, num_proposals + 1) - ]).results - ] + async def get_proposal_ids_by_state(self) -> dict[ProposalState, list[int]]: + proposal_contract = await self._get_proposal_contract() + num_proposals = await proposal_contract.functions.getTotal().call() + proposal_states = await rp.multicall( + [ + proposal_contract.functions.getState(proposal_id) + for proposal_id in range(1, num_proposals + 1) + ] + ) - proposals = {state: [] for state in ProtocolDAO.ProposalState} + proposals: dict[ProtocolDAO.ProposalState, list[int]] = { + state: [] for state in ProtocolDAO.ProposalState + } for proposal_id in range(1, num_proposals + 1): state = proposal_states[proposal_id - 1] proposals[state].append(proposal_id) return proposals - def fetch_proposal(self, proposal_id: int) -> Optional[Proposal]: - num_proposals = self.proposal_contract.functions.getTotal().call() - if not (1 <= proposal_id <= num_proposals): - return None - - # map results of functions calls to function name - multicall: dict[str, str | bytes | int] = { - res.function_name: res.results[0] for res in rp.multicall.aggregate([ - self.proposal_contract.functions.getProposer(proposal_id), - self.proposal_contract.functions.getMessage(proposal_id), - self.proposal_contract.functions.getPayload(proposal_id), - self.proposal_contract.functions.getCreated(proposal_id), - self.proposal_contract.functions.getStart(proposal_id), - self.proposal_contract.functions.getPhase1End(proposal_id), - self.proposal_contract.functions.getPhase2End(proposal_id), - self.proposal_contract.functions.getExpires(proposal_id), - self.proposal_contract.functions.getVotingPowerFor(proposal_id), - self.proposal_contract.functions.getVotingPowerAgainst(proposal_id), - self.proposal_contract.functions.getVotingPowerVeto(proposal_id), - self.proposal_contract.functions.getVotingPowerAbstained(proposal_id), - self.proposal_contract.functions.getVotingPowerRequired(proposal_id), - self.proposal_contract.functions.getVetoQuorum(proposal_id) - ]).results - } + async def fetch_proposal(self, proposal_id: int) -> Proposal: + proposal_contract = await self._get_proposal_contract() + ( + proposer, + message, + payload, + created, + start, + phase1_end, + phase2_end, + expires, + vp_for_raw, + vp_against_raw, + vp_veto_raw, + vp_abstain_raw, + vp_required_raw, + veto_quorum_raw, + ) = await rp.multicall( + [ + proposal_contract.functions.getProposer(proposal_id), + proposal_contract.functions.getMessage(proposal_id), + proposal_contract.functions.getPayload(proposal_id), + proposal_contract.functions.getCreated(proposal_id), + proposal_contract.functions.getStart(proposal_id), + proposal_contract.functions.getPhase1End(proposal_id), + proposal_contract.functions.getPhase2End(proposal_id), + proposal_contract.functions.getExpires(proposal_id), + proposal_contract.functions.getVotingPowerFor(proposal_id), + proposal_contract.functions.getVotingPowerAgainst(proposal_id), + proposal_contract.functions.getVotingPowerVeto(proposal_id), + proposal_contract.functions.getVotingPowerAbstained(proposal_id), + proposal_contract.functions.getVotingPowerRequired(proposal_id), + proposal_contract.functions.getVetoQuorum(proposal_id), + ] + ) return ProtocolDAO.Proposal( id=proposal_id, - proposer=cast(ChecksumAddress, multicall["getProposer"]), - message=multicall["getMessage"], - payload=multicall["getPayload"], - created=multicall["getCreated"], - start=multicall["getStart"], - end_phase_1=multicall["getPhase1End"], - end_phase_2= multicall["getPhase2End"], - expires=multicall["getExpires"], - votes_for=solidity.to_float(multicall["getVotingPowerFor"]), - votes_against=solidity.to_float(multicall["getVotingPowerAgainst"]), - votes_veto=solidity.to_float(multicall["getVotingPowerVeto"]), - votes_abstain=solidity.to_float(multicall["getVotingPowerAbstained"]), - quorum=solidity.to_float(multicall["getVotingPowerRequired"]), - veto_quorum=solidity.to_float(multicall["getVetoQuorum"]) + proposer=cast(ChecksumAddress, proposer), + message=message, + payload=payload, + created=created, + start=start, + end_phase_1=phase1_end, + end_phase_2=phase2_end, + expires=expires, + votes_for=solidity.to_float(vp_for_raw), + votes_against=solidity.to_float(vp_against_raw), + votes_veto=solidity.to_float(vp_veto_raw), + votes_abstain=solidity.to_float(vp_abstain_raw), + quorum=solidity.to_float(vp_required_raw), + veto_quorum=solidity.to_float(veto_quorum_raw), ) - def _build_vote_graph(self, proposal: Proposal) -> str: + def _build_vote_graph(self, proposal: DAO.Proposal) -> str: + assert isinstance(proposal, ProtocolDAO.Proposal) graph = tpl.figure() graph.barh( [ round(proposal.votes_for), round(proposal.votes_against), round(proposal.votes_abstain), - round(max(proposal.votes_total, proposal.quorum)) + round(max(proposal.votes_total, proposal.quorum)), ], ["For", "Against", "Abstain", ""], - max_width=12 + max_width=12, ) main_quorum_perc = proposal.votes_total / proposal.quorum - lines = graph.get_string().split("\n")[:-1] - lines.append(f"Quorum: {main_quorum_perc:.2%}{' ✔' if (main_quorum_perc >= 1) else ''}") - + lines = str(graph.get_string()).split("\n")[:-1] + lines.append( + f"Quorum: {main_quorum_perc:.2%}{' ✔' if (main_quorum_perc >= 1) else ''}" + ) + if proposal.votes_veto > 0: graph = tpl.figure() graph.barh( @@ -302,14 +359,16 @@ def _build_vote_graph(self, proposal: Proposal) -> str: round(proposal.votes_veto), round(max(proposal.votes_veto, proposal.veto_quorum)), ], - [f"{'Veto' : <{len('Against')}}", ""], - max_width=12 + [f"{'Veto': <{len('Against')}}", ""], + max_width=12, ) - veto_graph_bars = graph.get_string().split("\n") + veto_graph_bars = graph.get_string().split("\n") veto_quorum_perc = proposal.votes_veto / proposal.veto_quorum - + lines.append("") - lines.append(f"{veto_graph_bars[0] : <{len(veto_graph_bars[1])}}▏") - lines.append(f"Quorum: {veto_quorum_perc:.2%}{' ✔' if (veto_quorum_perc >= 1) else ''}") - + lines.append(f"{veto_graph_bars[0]: <{len(veto_graph_bars[1])}}▏") + lines.append( + f"Quorum: {veto_quorum_perc:.2%}{' ✔' if (veto_quorum_perc >= 1) else ''}" + ) + return "\n".join(lines) diff --git a/rocketwatch/utils/embeds.py b/rocketwatch/utils/embeds.py index a2347be4..4220e093 100644 --- a/rocketwatch/utils/embeds.py +++ b/rocketwatch/utils/embeds.py @@ -2,31 +2,30 @@ import datetime import logging import math -from typing import Optional, Callable, Literal +from collections.abc import Callable +import aiohttp import discord import humanize -import requests -from cachetools.func import ttl_cache -from discord import Color +from aiocache import cached +from discord import Color, Interaction from ens import InvalidName +from eth_typing import BlockIdentifier from etherscan_labels import Addresses +from web3.constants import ADDRESS_ZERO from strings import _ from utils import solidity -from utils.cached_ens import CachedEns -from utils.cfg import cfg -from utils.readable import cl_explorer_url, advanced_tnx_url, s_hex +from utils.block_time import block_to_ts +from utils.cached_ens import ens +from utils.config import cfg +from utils.readable import advanced_tnx_url, cl_explorer_url, s_hex +from utils.retry import retry from utils.rocketpool import rp from utils.sea_creatures import get_sea_creature_for_address from utils.shared_w3 import w3 -from utils.retry import retry -from utils.block_time import block_to_ts -ens = CachedEns() - -log = logging.getLogger("embeds") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.embeds") class Embed(discord.Embed): @@ -37,8 +36,8 @@ def __init__(self, *args, **kwargs): def set_footer_parts(self, parts): footer_parts = ["Created by 0xinvis.eth, Developed by haloooloolo.eth"] - if cfg["rocketpool.chain"] != "mainnet": - footer_parts.insert(-1, f"Chain: {cfg['rocketpool.chain'].capitalize()}") + if cfg.rocketpool.chain != "mainnet": + footer_parts.insert(-1, f"Chain: {cfg.rocketpool.chain.capitalize()}") footer_parts.extend(parts) self.set_footer(text=" · ".join(footer_parts)) @@ -47,190 +46,239 @@ def set_footer_parts(self, parts): # If an ens name is provided, it will be used as the display name. # If an address is provided, the display name will either be the reverse record or the address. # If the user input isn't sanitary, send an error message back to the user and return None, None. -async def resolve_ens(ctx, node_address): +async def resolve_ens(interaction: Interaction, node_address: str): # if it looks like an ens, attempt to resolve it if "." in node_address: try: - address = ens.resolve_name(node_address) + address = await ens.resolve_name(node_address) if not address: - await ctx.send("ENS name not found") + await interaction.followup.send("ENS name not found") return None, None return node_address, address except InvalidName: - await ctx.send("Invalid ENS name") + await interaction.followup.send("Invalid ENS name") return None, None # if it's just an address, look for a reverse record try: - address = w3.toChecksumAddress(node_address) + address = w3.to_checksum_address(node_address) except Exception: - await ctx.send("Invalid address") + await interaction.followup.send("Invalid address") return None, None try: - display_name = ens.get_name(node_address) or address + display_name = await ens.get_name(node_address) or address return display_name, address except InvalidName: - await ctx.send("Invalid address") + await interaction.followup.send("Invalid address") return None, None -@ttl_cache(ttl=900) -def get_pdao_delegates() -> dict[str, str]: - @retry(tries=3, delay=1) - def _get_delegates() -> dict[str, str]: - response = requests.get("https://delegates.rocketpool.net/api/delegates") - return {delegate["nodeAddress"]: delegate["name"] for delegate in response.json()} +_pdao_delegates: dict[str, str] = {} + +@cached(ttl=900) +@retry(tries=3, delay=1) +async def get_pdao_delegates() -> dict[str, str]: + global _pdao_delegates try: - return _get_delegates() + async with ( + aiohttp.ClientSession() as session, + session.get("https://delegates.rocketpool.net/api/delegates") as resp, + ): + _pdao_delegates = {d["nodeAddress"]: d["name"] for d in await resp.json()} except Exception: log.warning("Failed to fetch pDAO delegates.") - return {} + return _pdao_delegates -def el_explorer_url( - target: str, - name: str = "", - prefix: str | Literal[-1] = "", - name_fmt: Optional[Callable[[str], str]] = None, - block="latest" +async def el_explorer_url( + target: str, + name: str = "", + prefix: str | None = "", + name_fmt: Callable[[str], str] | None = None, + block: BlockIdentifier = "latest", ): + _prefix = "" - if w3.isAddress(target): + if w3.is_address(target): # sanitize address - url = f"{cfg['execution_layer.explorer']}/address/{target}" - target = w3.toChecksumAddress(target) - - # rocketscan url stuff - rocketscan_chains = { - "mainnet": "https://rocketscan.io", - "holesky": "https://holesky.rocketscan.io", - } + target = w3.to_checksum_address(target) + url = f"{cfg.execution_layer.explorer}/address/{target}" - if cfg["rocketpool.chain"] in rocketscan_chains: - rocketscan_url = rocketscan_chains[cfg["rocketpool.chain"]] - - if rp.call("rocketMinipoolManager.getMinipoolExists", target, block=block): - url = f"{rocketscan_url}/minipool/{target}" - elif rp.call("rocketNodeManager.getNodeExists", target, block=block): - if rp.call("rocketNodeManager.getSmoothingPoolRegistrationState", target, block=block) and prefix != -1: - prefix += ":cup_with_straw:" - url = f"{rocketscan_url}/node/{target}" + chain = cfg.rocketpool.chain + dashboard_network = "" if (chain == "mainnet") else f"?network={chain}" n_key = f"addresses.{target}" if not name and (n := _(n_key)) != n_key: name = n - if not name and (member_id := rp.call("rocketDAONodeTrusted.getMemberID", target, block=block)): - if prefix != -1: - prefix += "🔮" - name = member_id - - if not name and (member_id := rp.call("rocketDAOSecurity.getMemberID", target, block=block)): - if prefix != -1: - prefix += "🔒" - name = member_id - - if not name and (delegate_name := get_pdao_delegates().get(target)): - if prefix != -1: - prefix += "🏛️" - name = delegate_name - - if not name and cfg["rocketpool.chain"] != "mainnet": + if await rp.is_node(target): + megapool_address = await rp.call( + "rocketNodeManager.getMegapoolAddress", target + ) + if megapool_address != ADDRESS_ZERO: + url = f"https://rocketdash.net/megapool/{megapool_address}{dashboard_network}" + if await rp.call( + "rocketNodeManager.getSmoothingPoolRegistrationState", + target, + block=block, + ): + _prefix += ":cup_with_straw:" + if not name: + if member_id := await rp.call( + "rocketDAONodeTrusted.getMemberID", target, block=block + ): + _prefix += "🔮" + name = member_id + elif member_id := await rp.call( + "rocketDAOSecurity.getMemberID", target, block=block + ): + _prefix += "🔒" + name = member_id + elif delegate_name := (await get_pdao_delegates()).get(target): + _prefix += "🏛️" + name = delegate_name + elif await rp.is_megapool(target): + url = f"https://rocketdash.net/megapool/{target}{dashboard_network}" + elif await rp.is_minipool(target): + if chain == "mainnet": + url = f"https://rocketexplorer.net/validator/{target}" + + if not name and cfg.rocketpool.chain != "mainnet": name = s_hex(target) if not name: a = Addresses.get(target) # don't apply name if it has label is one with the id "take-action", as these don't show up on the explorer - if (not a.labels or len(a.labels) != 1 or a.labels[0].id != "take-action") and a.name and "alert" not in a.name.lower(): + if all( + ( + ( + not a.labels + or len(a.labels) != 1 + or a.labels[0].id != "take-action" + ), + a.name and ("alert" not in a.name.lower()), + ) + ): name = a.name if not name: - # not an odao member, try to get their ens - name = ens.get_name(target) - - if code := w3.eth.get_code(target): - if prefix != -1: - prefix += "📄" - if ( - not name - and w3.keccak(text=code.hex()).hex() - in cfg["other.mev_hashes"] + name = await ens.get_name(target) + + if code := await w3.eth.get_code(target): + _prefix += "📄" + if (not name) and ( + w3.keccak(text=code.hex()).hex() in cfg.other.mev_hashes ): name = "MEV Bot Contract" if not name: with contextlib.suppress(Exception): - c = w3.eth.contract(address=target, abi=[{"inputs" : [], - "name" : "name", - "outputs" : [{"internalType": "string", - "name" : "", - "type" : "string"}], - "stateMutability": "view", - "type" : "function"}]) - n = c.functions.name().call() + c = w3.eth.contract( + address=target, + abi=[ + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string", + } + ], + "stateMutability": "view", + "type": "function", + } + ], + ) + n = await c.functions.name().call() # make sure nobody is trying to inject a custom link, as there was a guy that made the name of his contract # 'RocketSwapRouter](https://etherscan.io/search?q=0x16d5a408e807db8ef7c578279beeee6b228f1c1c)[', # in an attempt to get people to click on his contract # first, if the name has a link in it, we ignore it - if any(keyword in n.lower() for keyword in - ["http", "discord", "airdrop", "telegram", "twitter", "youtube"]): + if any( + keyword in n.lower() + for keyword in [ + "http", + "discord", + "airdrop", + "telegram", + "twitter", + "youtube", + ] + ): log.warning(f"Contract {target} has a suspicious name: {n}") else: - name = f"{discord.utils.remove_markdown(n, ignore_links=False)}*" + name = ( + f"{discord.utils.remove_markdown(n, ignore_links=False)}*" + ) else: - # transaction_hash - url = f"{cfg['execution_layer.explorer']}/tx/{target}" + # transaction hash + url = f"{cfg.execution_layer.explorer}/tx/{target}" if not name: # fall back to shortened address name = s_hex(target) if name_fmt: name = name_fmt(name) - if prefix == -1: - prefix = "" + + prefix = "" if (prefix is None) else prefix + _prefix return f"{prefix}[{name}]({url})" -def prepare_args(args): +async def prepare_args(args): for arg_key, arg_value in list(args.items()): # store raw value args[f"{arg_key}_raw"] = arg_value # handle numbers - if any(keyword in arg_key.lower() for keyword in ["amount", "value", "rate", "totaleth", "stakingeth", "rethsupply", "rplprice", "profit"]) and isinstance(arg_value, int): - args[arg_key] = arg_value / 10 ** 18 + numeric_keywords = [ + "amount", + "value", + "rate", + "totaleth", + "stakingeth", + "rethsupply", + "rplprice", + "profit", + ] + if any( + keyword in arg_key.lower() for keyword in numeric_keywords + ) and isinstance(arg_value, int): + args[arg_key] = arg_value / 10**18 # handle timestamps if "deadline" in arg_key.lower() and isinstance(arg_value, int): - args[arg_key] = f"()" + args[arg_key] = f" ()" # handle percentages if "perc" in arg_key.lower(): - args[arg_key] = arg_value / 10 ** 16 + args[arg_key] = arg_value / 10**16 if arg_key.lower() in ["rate", "penalty"]: - args[f"{arg_key}_perc"] = arg_value / 10 ** 16 + args[f"{arg_key}_perc"] = arg_value / 10**16 # handle hex strings if str(arg_value).startswith("0x"): prefix = "" - if w3.isAddress(arg_value): + if w3.is_address(arg_value): # get rocketpool related holdings value for this address - address = w3.toChecksumAddress(arg_value) - prefix = get_sea_creature_for_address(address) + address = w3.to_checksum_address(arg_value) + prefix = await get_sea_creature_for_address(address) - # handle validators if arg_key == "pubkey": - args[arg_key] = cl_explorer_url(arg_value) + args[arg_key] = await cl_explorer_url(arg_value) elif arg_key == "cow_uid": args[arg_key] = f"[ORDER](https://explorer.cow.fi/orders/{arg_value})" else: - args[arg_key] = el_explorer_url(arg_value, prefix=prefix) - args[f'{arg_key}_clean'] = el_explorer_url(arg_value) + args[arg_key] = await el_explorer_url(arg_value, prefix=prefix) + args[f"{arg_key}_clean"] = await el_explorer_url(arg_value) if len(arg_value) == 66: - args[f'{arg_key}_small'] = el_explorer_url(arg_value, name="[tnx]") + args[f"{arg_key}_small"] = await el_explorer_url( + arg_value, name="[tnx]" + ) if "from" in args: args["fancy_from"] = args["from"] if "caller" in args and args["from"] != args["caller"]: @@ -238,65 +286,114 @@ def prepare_args(args): return args -def assemble(args) -> Embed: +async def assemble(args) -> Embed: e = Embed() - if args.event_name in ["service_interrupted", "finality_delay_event"]: - e.colour = Color.from_rgb(235, 86, 86) - if "sell_rpl" in args.event_name: - e.colour = Color.from_rgb(235, 86, 86) - if "buy_rpl" in args.event_name or "finality_delay_recover_event" in args.event_name: - e.colour = Color.from_rgb(86, 235, 86) - if "price_update_event" in args.event_name: - e.colour = Color.from_rgb(86, 235, 235) + if ( + args.event_name in ["service_interrupted", "finality_delay_event"] + or "sell_rpl" in args.event_name + or "sell_reth" in args.event_name + ): + e.colour = Color.from_rgb(235, 86, 86) # red + elif ( + "buy_rpl" in args.event_name + or "buy_reth" in args.event_name + or "finality_delay_recover_event" in args.event_name + ): + e.colour = Color.from_rgb(86, 235, 86) # green + elif "price_update_event" in args.event_name: + e.colour = Color.from_rgb(86, 235, 235) # pink # do this here before the amounts are converted to a string amount = args.get("amount") or args.get("ethAmount", 0) # raise Exception(str((args, args.assets, args.event_name))) - if any(( - ("pool_deposit" in args.event_name and amount >= 1000), - (args.event_name == "eth_deposit_event" and amount >= 500), - (args.event_name == "cs_deposit_eth_event" and args.assets >= 500) - )): + if ("pool_deposit" in args.event_name) and (amount >= 1000): e.set_image(url="https://media.giphy.com/media/VIX2atZr8dCKk5jF6L/giphy.gif") - elif any(kw in args.event_name for kw in ["_scrub_event", "_dissolve_event", "_slash_event", "finality_delay_event"]): + elif any( + kw in args.event_name + for kw in [ + "_scrub_event", + "_dissolve_event", + "_slash_event", + "finality_delay_event", + ] + ): e.set_image(url="https://c.tenor.com/p3hWK5YRo6IAAAAC/this-is-fine-dog.gif") + elif "_penalty" in args.event_name: + e.set_image(url="https://i.giphy.com/jmSjPi6soIoQCFwaXJ.webp") elif "_proposal_smoothie_" in args.event_name: - e.set_image(url="https://cdn.discordapp.com/attachments/812745786638336021/1106983677130461214/butta-commie-filter.png") - elif "sdao_member_kick_multi" in args.event_name: - e.set_image(url="https://media1.tenor.com/m/Xuv3IEoH1a4AAAAC/youre-fired-donald-trump.gif") + e.set_image( + url="https://cdn.discordapp.com/attachments/812745786638336021/1106983677130461214/butta-commie-filter.png" + ) + elif "sdao_member_kick" in args.event_name: + e.set_image( + url="https://media1.tenor.com/m/Xuv3IEoH1a4AAAAC/youre-fired-donald-trump.gif" + ) match args.event_name: case "cs_max_validator_increase_event": - e.set_image(url="https://media1.tenor.com/m/Yp6Yeiufb04AAAAd/piranhas-feeding.gif") + e.set_image( + url="https://media1.tenor.com/m/Yp6Yeiufb04AAAAd/piranhas-feeding.gif" + ) case "redstone_upgrade_triggered": - e.set_image(url="https://cdn.dribbble.com/users/187497/screenshots/2284528/media/123903807d334c15aa105b44f2bd9252.gif") + url = "https://cdn.dribbble.com/users/187497/screenshots/2284528/media/123903807d334c15aa105b44f2bd9252.gif" + e.set_image(url=url) case "atlas_upgrade_triggered": - e.set_image(url="https://cdn.discordapp.com/attachments/912434217118498876/1097528472567558227/DALLE_2023-04-17_16.25.46_-_an_expresive_oil_painting_of_the_atlas_2_rocket_taking_off_moon_colorfull.png") + url = ( + "https://cdn.discordapp.com/attachments/912434217118498876/1097528472567558227/" + "DALLE_2023-04-17_16.25.46_-_an_expresive_oil_painting_of_the_atlas_2_rocket_taking_off_moon_colorfull.png" + ) + e.set_image(url=url) case "houston_upgrade_triggered": e.set_image(url="https://i.imgur.com/XT5qPWf.png") case "houston_hotfix_upgrade_triggered": e.set_image(url="https://i.imgur.com/JcQS3Sh.png") + case "saturn_one_upgrade_triggered": + e.set_image(url="https://i.imgur.com/n3wMCOA.png") match args.event_name: case "pdao_set_delegate": - use_large = (args.votingPower >= 250) + use_large = args.votingPower >= 200 case "eth_deposit_event": - use_large = (amount >= 32) + use_large = amount >= 32 case "rpl_stake_event": - use_large = (amount >= ((3 * 2.4) / solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice")))) + use_large = amount >= ( + (3 * 2.4) + / solidity.to_float(await rp.call("rocketNetworkPrices.getRPLPrice")) + ) + case "rpl_migration_event": + use_large = amount >= 1000 case "cs_deposit_eth_event" | "cs_withdraw_eth_event": - use_large = (args["assets"] >= 32) + use_large = args["assets"] >= 100 case "cs_deposit_rpl_event" | "cs_withdraw_rpl_event": - use_large = (args["assets"] >= 16 / solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice"))) - case "exit_arbitrage_event": - use_large = args["amount"] >= 100 + use_large = args["assets"] >= 16 / solidity.to_float( + await rp.call("rocketNetworkPrices.getRPLPrice") + ) + case "rocksolid_deposit_event": + use_large = args["assets"] >= 50 + case "rocksolid_withdrawal_event": + use_large = args["shares"] >= 50 + case "validator_multi_deposit_event": + use_large = args["numberOfValidators"] >= 5 case _: - use_large = (amount >= 100) + use_large = amount >= 100 # make numbers look nice for arg_key, arg_value in list(args.items()): - if any(keyword in arg_key.lower() for keyword in - ["amount", "value", "total_supply", "perc", "tnx_fee", "rate", "votingpower", "assets", "shares", "profit"]): + if any( + keyword in arg_key.lower() + for keyword in [ + "amount", + "value", + "total_supply", + "perc", + "tnx_fee", + "rate", + "votingpower", + "assets", + "shares", + "profit", + ] + ): if not isinstance(arg_value, (int, float)) or "raw" in arg_key: continue if arg_value: @@ -307,14 +404,20 @@ def assemble(args) -> Embed: arg_value = int(arg_value) args[arg_key] = humanize.intcomma(arg_value) - has_small = _(f"embeds.{args.event_name}.description_small") != f"embeds.{args.event_name}.description_small" - has_large = _(f"embeds.{args.event_name}.description") != f"embeds.{args.event_name}.description" + has_small = ( + _(f"embeds.{args.event_name}.description_small") + != f"embeds.{args.event_name}.description_small" + ) + has_large = ( + _(f"embeds.{args.event_name}.description") + != f"embeds.{args.event_name}.description" + ) if has_small and not (has_large and use_large): e.description = _(f"embeds.{args.event_name}.description_small", **args) e.description += f" {args.transactionHash_small}" - if cfg["rocketpool.chain"] != "mainnet": - e.description += f" ({cfg['rocketpool.chain'].capitalize()})" + if cfg.rocketpool.chain != "mainnet": + e.description += f" ({cfg.rocketpool.chain.capitalize()})" e.set_footer(text="") return e @@ -322,16 +425,19 @@ def assemble(args) -> Embed: e.description = _(f"embeds.{args.event_name}.description", **args) if "cow_uid" in args: - e.add_field(name="Cow Order", - value=args.cow_uid, - inline=False) + e.add_field(name="CoW Order", value=args.cow_uid, inline=False) if "exchangeRate" in args: - e.add_field(name="Exchange Rate", - value=f"`{args.exchangeRate} RPL/{args.otherToken}`" + - ( - f" (`{args.discountAmount}%` Discount, oDAO: `{args.marketExchangeRate} RPL/ETH`)" if "discountAmount" in args else ""), - inline=False) + e.add_field( + name="Exchange Rate", + value=f"`{args.exchangeRate} RPL/{args.otherToken}`" + + ( + f" (`{args.discountAmount}%` Discount, oDAO: `{args.marketExchangeRate} RPL/ETH`)" + if "discountAmount" in args + else "" + ), + inline=False, + ) """ # show public key if we have one @@ -342,21 +448,19 @@ def assemble(args) -> Embed: """ if "epoch" in args: - e.add_field(name="Epoch", - value=f"[{args.epoch}](https://{cfg['consensus_layer.explorer']}/epoch/{args.epoch})") + e.add_field( + name="Epoch", + value=f"[{args.epoch}](https://{cfg.consensus_layer.explorer}/epoch/{args.epoch})", + ) if "timezone" in args: - e.add_field(name="Timezone", - value=f"`{args.timezone}`", - inline=False) + e.add_field(name="Timezone", value=f"`{args.timezone}`", inline=False) if "node_operator" in args: - e.add_field(name="Node Operator", - value=args.node_operator) + e.add_field(name="Node Operator", value=args.node_operator) if "slashing_type" in args: - e.add_field(name="Reason", - value=f"`{args.slashing_type} Violation`") + e.add_field(name="Reason", value=f"`{args.slashing_type} Violation`") """ if "commission" in args: @@ -366,140 +470,123 @@ def assemble(args) -> Embed: """ if "invoiceID" in args: - e.add_field( - name="Invoice ID", - value=f"`{args.invoiceID}`", - inline=False - ) + e.add_field(name="Invoice ID", value=f"`{args.invoiceID}`", inline=False) - if "contractName" in args: + if "settingContractName" in args: e.add_field( - name="Contract", - value=f"`{args.contractName}`", - inline=False + name="Contract", value=f"`{args.settingContractName}`", inline=False ) - if "settingContractName" in args: - e.add_field(name="Contract", - value=f"`{args.settingContractName}`", - inline=False) - if "periodLength" in args: e.add_field( name="Payment Interval", value=humanize.naturaldelta(datetime.timedelta(seconds=args.periodLength)), - inline=False + inline=False, ) if "startTime" in args: e.add_field( name="First Payment", value=f"", - inline=False + inline=False, ) if "index" in args: - e.add_field( - name="Index", - value=args.index, - inline=True - ) + e.add_field(name="Index", value=args.index, inline=True) if "challengePeriod" in args: e.add_field( name="Challenge Period", - value=humanize.naturaldelta(datetime.timedelta(seconds=args.challengePeriod)), - inline=True + value=humanize.naturaldelta( + datetime.timedelta(seconds=args.challengePeriod) + ), + inline=True, ) if "proposalBond" in args: - e.add_field( - name="Proposal Bond", - value=f"{args.proposalBond} RPL", - inline=True - ) + e.add_field(name="Proposal Bond", value=f"{args.proposalBond} RPL", inline=True) if "challengeBond" in args: e.add_field( - name="Challenge Bond", - value=f"{args.challengeBond} RPL", - inline=True + name="Challenge Bond", value=f"{args.challengeBond} RPL", inline=True ) - if "contractAddress" in args and "Contract" in args.type: - e.add_field(name="Contract Address", - value=args.contractAddress, - inline=False) + if "contractAddress" in args and "Contract" in args.get("type", ""): + e.add_field(name="Contract Address", value=args.contractAddress, inline=False) if "url" in args: - e.add_field(name="URL", - value=args.url, - inline=False) + e.add_field(name="URL", value=args.url, inline=False) # show current inflation if "inflation" in args: - e.add_field(name="Current Inflation", - value=f"{args.inflation}%", - inline=False) + e.add_field(name="Current Inflation", value=f"{args.inflation}%", inline=False) if "submission" in args and "merkleTreeCID" in args.submission: n = f"0x{s_hex(args.submission.merkleRoot.hex())}" - e.add_field(name="Merkle Tree", - value=f"[{n}](https://gateway.ipfs.io/ipfs/{args.submission.merkleTreeCID})") + e.add_field( + name="Merkle Tree", + value=f"[{n}](https://gateway.ipfs.io/ipfs/{args.submission.merkleTreeCID})", + ) # show transaction hash if possible if "transactionHash" in args: content = f"{args.transactionHash}{advanced_tnx_url(args.transactionHash_raw)}" - e.add_field(name="Transaction Hash", - value=content) + e.add_field(name="Transaction Hash", value=content) # show sender address - if senders := [value for key, value in args.items() if key.lower() in ["sender", "from"]]: + if senders := [ + value for key, value in args.items() if key.lower() in ["sender", "from"] + ]: sender = senders[0] v = sender # if args["origin"] is an address and does not match the sender, show both if "caller" in args and args["caller"] != sender and "0x" in args["caller"]: v = f"{args.caller} ({sender})" - e.add_field(name="Sender Address", - value=v) + e.add_field(name="Sender Address", value=v) # show block number - el_explorer = cfg["execution_layer.explorer"] - if "blockNumber" in args: - e.add_field(name="Block Number", - value=f"[{args.blockNumber}]({el_explorer}/block/{args.blockNumber})") + el_explorer = cfg.execution_layer.explorer + if "block_number" in args: + e.add_field( + name="Block Number", + value=f"[{args.blockNumber}]({el_explorer}/block/{args.blockNumber})", + ) - cl_explorer = cfg["consensus_layer.explorer"] + cl_explorer = cfg.consensus_layer.explorer if "slot" in args: - e.add_field(name="Slot", - value=f"[{args.slot}]({cl_explorer}/slot/{args.slot})") + e.add_field(name="Slot", value=f"[{args.slot}]({cl_explorer}/slot/{args.slot})") if "smoothie_amount" in args: - e.add_field(name="Smoothing Pool Balance", - value=f"||{args.smoothie_amount}|| ETH") + e.add_field( + name="Smoothing Pool Balance", value=f"||{args.smoothie_amount}|| ETH" + ) - if "reason" in args and args["reason"]: - e.add_field(name="Likely Revert Reason", - value=f"`{args.reason}`", - inline=False) + if args.get("reason"): + e.add_field(name="Likely Revert Reason", value=f"`{args.reason}`", inline=False) # show timestamp - if "time" in args.keys(): + if "time" in args: times = [args["time"]] else: times = [value for key, value in args.items() if "time" in key.lower()] if block := args.get("blockNumber"): - times += [block_to_ts(block)] + times += [await block_to_ts(block)] time = times[0] if times else int(datetime.datetime.now().timestamp()) - e.add_field(name="Timestamp", - value=f" ()", - inline=False) + e.add_field(name="Timestamp", value=f" ()", inline=False) # show the transaction fees if "tnx_fee" in args: - e.add_field(name="Transaction Fee", - value=f"{args.tnx_fee} ETH ({args.tnx_fee_usd} USDC)", - inline=False) + tnx_fee_wei = args.tnx_fee_raw + if tnx_fee_wei >= 10**15: + tnx_fee_eth = round(tnx_fee_wei / 10**18, 3) + value = f"{tnx_fee_eth:,} ETH ({args.tnx_fee_usd} USDC)" + elif tnx_fee_wei >= 10**9: + tnx_fee_gwei = round(tnx_fee_wei / 10**9) + value = f"{tnx_fee_gwei:,} Gwei ({args.tnx_fee_usd} USDC)" + else: + value = f"{tnx_fee_wei:,} Wei ({args.tnx_fee_usd} USDC)" + + e.add_field(name="Transaction Fee", value=value, inline=False) return e diff --git a/rocketwatch/utils/etherscan.py b/rocketwatch/utils/etherscan.py index 0152030d..523ee451 100644 --- a/rocketwatch/utils/etherscan.py +++ b/rocketwatch/utils/etherscan.py @@ -2,47 +2,50 @@ import aiohttp -from utils.cfg import cfg +from utils.config import cfg from utils.shared_w3 import w3 -log = logging.getLogger("etherscan") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.etherscan") async def get_recent_account_transactions(address, block_count=44800): ETHERSCAN_URL = "https://api.etherscan.io/api" - highest_block = w3.eth.get_block("latest")["number"] + highest_block = (await w3.eth.get_block("latest"))["number"] page = 1 lowest_block = highest_block - block_count async with aiohttp.ClientSession() as session: - resp = await session.get(ETHERSCAN_URL, params={"address" : address, - "page" : page, - "apikey" : cfg["execution_layer.etherscan_secret"], - "module" : "account", - "action" : "txlist", - "sort" : "desc", - "startblock": lowest_block, - "endblock" : highest_block}) - - if not resp.status == 200: - log.debug( - f"Error querying etherscan, unexpected HTTP {str(resp.status)}") + resp = await session.get( + ETHERSCAN_URL, + params={ + "address": address, + "page": page, + "apikey": cfg.execution_layer.etherscan_secret, + "module": "account", + "action": "txlist", + "sort": "desc", + "startblock": lowest_block, + "endblock": highest_block, + }, + ) + + if resp.status != 200: + log.debug(f"Error querying etherscan, unexpected HTTP {resp.status!s}") return parsed = await resp.json() - if "message" not in parsed or not parsed["message"].lower() == "ok": - error = parsed["message"] if "message" in parsed else "" - r = parsed["result"] if "result" in parsed else "" + if "message" not in parsed or parsed["message"].lower() != "ok": + error = parsed.get("message", "") + r = parsed.get("result", "") log.debug(f"Error querying {resp.url} - {error} - {r}") return def valid_tx(tx): - if not tx["to"] == address.lower(): + if tx["to"] != address.lower(): return False - if not int(tx["isError"]) == 0: - return False - return True + return int(tx["isError"]) == 0 - return {result["hash"]: result for result in parsed["result"] if valid_tx(result)} + return { + result["hash"]: result for result in parsed["result"] if valid_tx(result) + } diff --git a/rocketwatch/utils/event.py b/rocketwatch/utils/event.py index ee16c4dd..2b441df7 100644 --- a/rocketwatch/utils/event.py +++ b/rocketwatch/utils/event.py @@ -1,16 +1,19 @@ +from __future__ import annotations + from abc import abstractmethod from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Optional +from typing import TYPE_CHECKING from discord.ext import commands from eth_typing import BlockNumber -from utils.shared_w3 import w3 -from utils.cfg import cfg +if TYPE_CHECKING: + from rocketwatch.rocketwatch import RocketWatch +from utils.config import cfg from utils.embeds import Embed from utils.image import Image -from rocketwatch import RocketWatch +from utils.shared_w3 import w3 @dataclass(frozen=True, slots=True) @@ -22,38 +25,45 @@ class Event: block_number: BlockNumber transaction_index: int = 999 event_index: int = 999 - image: Optional[Image] = None - thumbnail: Optional[Image] = None + image: Image | None = None + thumbnail: Image | None = None def get_score(self): - return (10**9 * self.block_number) + (10**5 * self.transaction_index) + self.event_index + return ( + (10**9 * self.block_number) + + (10**5 * self.transaction_index) + + self.event_index + ) + class EventPlugin(commands.Cog): def __init__(self, bot: RocketWatch, rate_limit=timedelta(seconds=5)): self.bot = bot self.rate_limit = rate_limit - self.lookback_distance: int = cfg["events.lookback_distance"] - self.last_served_block = w3.eth.get_block(cfg["events.genesis"]).number - 1 + self.lookback_distance: int = cfg.events.lookback_distance + self.last_served_block = BlockNumber(cfg.events.genesis - 1) self._pending_block = self.last_served_block self._last_run = datetime.now() - rate_limit def start_tracking(self, block: BlockNumber) -> None: - self.last_served_block = block - 1 + self.last_served_block = BlockNumber(block - 1) - def get_new_events(self) -> list[Event]: + async def get_new_events(self) -> list[Event]: now = datetime.now() if (now - self._last_run) < self.rate_limit: return [] self._last_run = now - self._pending_block = w3.eth.get_block_number() - events = self._get_new_events() + self._pending_block = await w3.eth.get_block_number() + events = await self._get_new_events() self.last_served_block = self._pending_block return events @abstractmethod - def _get_new_events(self) -> list[Event]: + async def _get_new_events(self) -> list[Event]: pass - def get_past_events(self, from_block: BlockNumber, to_block: BlockNumber) -> list[Event]: + async def get_past_events( + self, from_block: BlockNumber, to_block: BlockNumber + ) -> list[Event]: return [] diff --git a/rocketwatch/utils/event_logs.py b/rocketwatch/utils/event_logs.py index 12aca6cb..ff9ba3c4 100644 --- a/rocketwatch/utils/event_logs.py +++ b/rocketwatch/utils/event_logs.py @@ -1,40 +1,35 @@ +import asyncio import logging -from typing import Optional, Any +from typing import Any -from eth_typing import BlockNumber -from web3.contract import ContractEvent, LogReceipt +from eth_typing import BlockNumber +from web3.contract.async_contract import AsyncContractEvent +from web3.types import EventData -from utils.cfg import cfg +log = logging.getLogger("rocketwatch.event_logs") -log = logging.getLogger("event_logs") -log.setLevel(cfg["log_level"]) - -def get_logs( - event: ContractEvent, - from_block: BlockNumber, - to_block: BlockNumber, - arg_filters: Optional[dict[str, Any]] = None -) -> list[LogReceipt]: - start_block = from_block - end_block = to_block - - log.debug(f"Fetching vote receipts in [{start_block}, {end_block}]") +async def get_logs( + event: AsyncContractEvent, + from_block: BlockNumber, + to_block: BlockNumber, + arg_filters: dict[str, Any] | None = None, +) -> list[EventData]: + log.debug(f"Fetching event logs in [{from_block}, {to_block}]") chunk_size = 50_000 - from_block = start_block - to_block = from_block + chunk_size - - logs = [] - - while from_block <= end_block: - logs += event.create_filter( - fromBlock=from_block, - toBlock=min(to_block, end_block), - argument_filters=arg_filters - ).get_all_entries() - - from_block = to_block + 1 - to_block = from_block + chunk_size - - return logs + tasks = [] + chunk_start = from_block + while chunk_start <= to_block: + chunk_end = min(chunk_start + chunk_size, to_block) + tasks.append( + event.get_logs( + from_block=chunk_start, + to_block=chunk_end, + argument_filters=arg_filters, + ) + ) + chunk_start = BlockNumber(chunk_end + 1) + + results = await asyncio.gather(*tasks) + return [log_entry for chunk in results for log_entry in chunk] diff --git a/rocketwatch/utils/file.py b/rocketwatch/utils/file.py new file mode 100644 index 00000000..67acc704 --- /dev/null +++ b/rocketwatch/utils/file.py @@ -0,0 +1,7 @@ +import io + +from discord import File + + +def TextFile(content: str, filename: str) -> File: + return File(io.BytesIO(content.encode()), filename) diff --git a/rocketwatch/utils/image.py b/rocketwatch/utils/image.py index 99c0b468..1725cd00 100644 --- a/rocketwatch/utils/image.py +++ b/rocketwatch/utils/image.py @@ -1,16 +1,16 @@ import math -from enum import Enum -from io import BytesIO +from enum import StrEnum from functools import cache -from typing import Optional +from io import BytesIO from discord import File -from PIL import ImageFont, Image as PillowImage +from PIL import Image as PillowImage +from PIL import ImageFont from PIL.ImageDraw import ImageDraw - Color = tuple[int, int, int] + class Image: def __init__(self, image: PillowImage.Image): self.__img = image @@ -22,29 +22,29 @@ def to_file(self, name: str) -> File: return File(buffer, name) -class Font(str, Enum): +class Font(StrEnum): INTER = "Inter" -class FontVariant(str, Enum): +class FontVariant(StrEnum): REGULAR = "Regular" BOLD = "Bold" class ImageCanvas(ImageDraw): # default color matches Discord mobile dark mode Embed - def __init__(self, width: int, height: int, bg_color: Color = (37, 39, 26)): - p_img = PillowImage.new('RGB', (width, height), color=bg_color) + def __init__(self, width: int, height: int, bg_color: Color = (57, 58, 64)): + p_img = PillowImage.new("RGB", (width, height), color=bg_color) super().__init__(p_img) self.image = Image(p_img) def progress_bar( - self, - xy: tuple[float, float], - size: tuple[float, float], - progress: float, - fill_color: Color, - bg_color : Color = (0, 0, 0) + self, + xy: tuple[float, float], + size: tuple[float, float], + progress: float, + fill_color: Color, + bg_color: Color = (0, 0, 0), ) -> None: x, y = xy width, height = size @@ -59,32 +59,45 @@ def progress_bar( # left semicircle fill_perc = min(1.0, fill_width / radius) angle = 90 * (1 + 2 * math.acos(fill_perc) / math.pi) - self.chord((x, y, x + 2 * radius, y + height), angle, 360 - angle, fill_color) + self.chord( + (x, y, x + 2 * radius, y + height), angle, 360 - angle, fill_color + ) if fill_width > radius: # main bar - self.rectangle((x + radius, y, x + min(fill_width, width - radius), y + height), fill_color) + self.rectangle( + (x + radius, y, x + min(fill_width, width - radius), y + height), + fill_color, + ) if fill_width > (width - radius): # right semicircle fill_perc = min(1.0, (fill_width - width + radius) / radius) angle = 90 * (2 * math.acos(fill_perc) / math.pi) - self.chord((x + width - 2 * radius, y, x + width, y + height), angle, 360 - angle, fill_color) - + self.chord( + (x + width - 2 * radius, y, x + width, y + height), + angle, + 360 - angle, + fill_color, + ) + + @staticmethod @cache - def _get_font(self, name: str, variant: FontVariant, size: float) -> ImageFont: + def _get_font( + name: str, variant: FontVariant, size: float + ) -> ImageFont.FreeTypeFont: return ImageFont.truetype(f"fonts/{name}-{variant}.ttf", size) def dynamic_text( - self, - xy: tuple[float, float], - text: str, - font_size: float, - font_name: Font = Font.INTER, - font_variant: FontVariant = FontVariant.REGULAR, - color: Color = (255, 255, 255), - max_width: Optional[float] = None, - anchor: str = "lt" + self, + xy: tuple[float, float], + text: str, + font_size: float, + font_name: Font = Font.INTER, + font_variant: FontVariant = FontVariant.REGULAR, + color: Color = (255, 255, 255), + max_width: float | None = None, + anchor: str = "lt", ) -> None: font = self._get_font(font_name, font_variant, font_size) if max_width is not None: diff --git a/rocketwatch/utils/liquidity.py b/rocketwatch/utils/liquidity.py index ec7ba950..134278f8 100644 --- a/rocketwatch/utils/liquidity.py +++ b/rocketwatch/utils/liquidity.py @@ -1,21 +1,19 @@ -import math import logging -from collections import OrderedDict +import math from abc import ABC, abstractmethod +from collections import OrderedDict +from collections.abc import Callable, Sequence from dataclasses import dataclass -from typing import Optional, Callable import aiohttp import numpy as np - from eth_typing import ChecksumAddress, HexStr -from utils.cfg import cfg -from utils.retry import retry_async +from utils.retry import retry from utils.rocketpool import rp +from utils.shared_w3 import w3 -log = logging.getLogger("liquidity") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.liquidity") class Liquidity: @@ -72,25 +70,27 @@ def _get_asks(self, api_response: dict) -> dict[float, float]: """Extract mapping of price to major-denominated ask liquidity from API response""" pass - @retry_async(tries=3, delay=1) + @retry(tries=3, delay=1) async def _get_order_book( - self, - market: Market, - session: aiohttp.ClientSession + self, market: Market, session: aiohttp.ClientSession ) -> tuple[dict[float, float], dict[float, float]]: params = self._get_request_params(market) url = self._api_base_url + self._get_request_path(market) - response = await session.get(url, params=params, headers={"User-Agent": "Rocket Watch"}) + response = await session.get( + url, params=params, headers={"User-Agent": "Rocket Watch"} + ) log.debug(f"response from {url}: {response}") data = await response.json() bids = OrderedDict(sorted(self._get_bids(data).items(), reverse=True)) asks = OrderedDict(sorted(self._get_asks(data).items())) return bids, asks - async def _get_liquidity(self, market: Market, session: aiohttp.ClientSession) -> Optional[Liquidity]: + async def _get_liquidity( + self, market: Market, session: aiohttp.ClientSession + ) -> Liquidity | None: bids, asks = await self._get_order_book(market, session) if not (bids and asks): - log.warning(f"Empty order book") + log.warning("Empty order book") return None bid_prices = np.array(list(bids.keys())) @@ -116,7 +116,9 @@ def depth_at(_price: float) -> float: return Liquidity(price, depth_at) - async def get_liquidity(self, session: aiohttp.ClientSession) -> dict[Market, Liquidity]: + async def get_liquidity( + self, session: aiohttp.ClientSession + ) -> dict[Market, Liquidity]: markets = {} for market in self.markets: if liq := await self._get_liquidity(market, session): @@ -166,10 +168,16 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"product_id": f"{market.major}-{market.minor}"} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(bid["price"]): float(bid["size"]) for bid in api_response["pricebook"]["bids"]} + return { + float(bid["price"]): float(bid["size"]) + for bid in api_response["pricebook"]["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(ask["price"]): float(ask["size"]) for ask in api_response["pricebook"]["asks"]} + return { + float(ask["price"]): float(ask["size"]) + for ask in api_response["pricebook"]["asks"] + } class Deepcoin(CEX): @@ -190,10 +198,14 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"instId": f"{market.major}-{market.minor}", "sz": 400} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["data"]["bids"]} + return { + float(price): float(size) for price, size in api_response["data"]["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["data"]["asks"]} + return { + float(price): float(size) for price, size in api_response["data"]["asks"] + } class GateIO(CEX): @@ -238,10 +250,16 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"instId": f"{market.major}-{market.minor}", "sz": 400} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size, _, _ in api_response["data"][0]["bids"]} + return { + float(price): float(size) + for price, size, _, _ in api_response["data"][0]["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size, _, _ in api_response["data"][0]["asks"]} + return { + float(price): float(size) + for price, size, _, _ in api_response["data"][0]["asks"] + } class Bitget(CEX): @@ -262,10 +280,14 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"symbol": f"{market.major}{market.minor}", "limit": 150} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["data"]["bids"]} + return { + float(price): float(size) for price, size in api_response["data"]["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["data"]["asks"]} + return { + float(price): float(size) for price, size in api_response["data"]["asks"] + } class MEXC(CEX): @@ -307,13 +329,21 @@ def _get_request_path(market: Market) -> str: @staticmethod def _get_request_params(market: Market) -> dict[str, str | int]: - return {"category": "spot", "symbol": f"{market.major}{market.minor}", "limit": 200} + return { + "category": "spot", + "symbol": f"{market.major}{market.minor}", + "limit": 200, + } def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["result"]["b"]} + return { + float(price): float(size) for price, size in api_response["result"]["b"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["result"]["a"]} + return { + float(price): float(size) for price, size in api_response["result"]["a"] + } class CryptoDotCom(CEX): @@ -337,10 +367,16 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"instrument_name": f"{market.major}_{market.minor}", "depth": 150} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size, _ in api_response["result"]["data"][0]["bids"]} + return { + float(price): float(size) + for price, size, _ in api_response["result"]["data"][0]["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size, _ in api_response["result"]["data"][0]["asks"]} + return { + float(price): float(size) + for price, size, _ in api_response["result"]["data"][0]["asks"] + } class Kraken(CEX): @@ -361,10 +397,16 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"pair": f"{market.major}{market.minor}", "count": 500} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size, _ in list(api_response["result"].values())[0]["bids"]} + return { + float(price): float(size) + for price, size, _ in next(iter(api_response["result"].values()))["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size, _ in list(api_response["result"].values())[0]["asks"]} + return { + float(price): float(size) + for price, size, _ in next(iter(api_response["result"].values()))["asks"] + } class Kucoin(CEX): @@ -385,10 +427,14 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"symbol": f"{market.major}-{market.minor}"} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["data"]["bids"]} + return { + float(price): float(size) for price, size in api_response["data"]["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["data"]["asks"]} + return { + float(price): float(size) for price, size in api_response["data"]["asks"] + } class Bithumb(CEX): @@ -409,10 +455,16 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"markets": f"{market.minor}-{market.major}"} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {entry["bid_price"]: entry["bid_size"] for entry in api_response[0]["orderbook_units"]} + return { + entry["bid_price"]: entry["bid_size"] + for entry in api_response[0]["orderbook_units"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {entry["ask_price"]: entry["ask_size"] for entry in api_response[0]["orderbook_units"]} + return { + entry["ask_price"]: entry["ask_size"] + for entry in api_response[0]["orderbook_units"] + } class BingX(CEX): @@ -433,10 +485,14 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"symbol": f"{market.major}-{market.minor}", "limit": 1000} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["data"]["bids"]} + return { + float(price): float(size) for price, size in api_response["data"]["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["data"]["asks"]} + return { + float(price): float(size) for price, size in api_response["data"]["asks"] + } class Bitvavo(CEX): @@ -478,13 +534,21 @@ def _get_request_path(market: Market) -> str: @staticmethod def _get_request_params(market: Market) -> dict[str, str | int]: - return {"symbol": f"{market.major.lower()}{market.minor.lower()}", "type": "step0"} + return { + "symbol": f"{market.major.lower()}{market.minor.lower()}", + "type": "step0", + } def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(entry[0]): float(entry[1]) for entry in api_response["tick"]["bids"]} + return { + float(entry[0]): float(entry[1]) for entry in api_response["tick"]["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(entry[0]): float(entry[1]) for entry in api_response["tick"]["asks"]} + return { + float(entry[0]): float(entry[1]) for entry in api_response["tick"]["asks"] + } + class BitMart(CEX): @property @@ -504,10 +568,14 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"symbol": f"{market.major}_{market.minor}", "limit": 50} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(entry[0]): float(entry[1]) for entry in api_response["data"]["bids"]} + return { + float(entry[0]): float(entry[1]) for entry in api_response["data"]["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(entry[0]): float(entry[1]) for entry in api_response["data"]["asks"]} + return { + float(entry[0]): float(entry[1]) for entry in api_response["data"]["asks"] + } class Bitrue(CEX): @@ -528,10 +596,16 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"symbol": f"{market.major}{market.minor}"} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(entry[0]): float(entry[1]) for entry in api_response["data"]["tick"]["b"]} + return { + float(entry[0]): float(entry[1]) + for entry in api_response["data"]["tick"]["b"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(entry[0]): float(entry[1]) for entry in api_response["data"]["tick"]["a"]} + return { + float(entry[0]): float(entry[1]) + for entry in api_response["data"]["tick"]["a"] + } class CoinTR(CEX): @@ -552,10 +626,14 @@ def _get_request_params(market: Market) -> dict[str, str | int]: return {"symbol": f"{market.major}{market.minor}", "limit": 150} def _get_bids(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["data"]["bids"]} + return { + float(price): float(size) for price, size in api_response["data"]["bids"] + } def _get_asks(self, api_response: dict) -> dict[float, float]: - return {float(price): float(size) for price, size in api_response["data"]["asks"]} + return { + float(price): float(size) for price, size in api_response["data"]["asks"] + } class DigiFinex(CEX): @@ -583,11 +661,19 @@ def _get_asks(self, api_response: dict) -> dict[float, float]: class ERC20Token: - def __init__(self, address: ChecksumAddress): + def __init__(self, address: ChecksumAddress, symbol: str, decimals: int): self.address = address - contract = rp.assemble_contract("ERC20", address, mainnet=True) - self.symbol: str = contract.functions.symbol().call() - self.decimals: int = contract.functions.decimals().call() + self.symbol = symbol + self.decimals = decimals + + @classmethod + async def create(cls, address: ChecksumAddress) -> "ERC20Token": + address = w3.to_checksum_address(address) + contract = await rp.assemble_contract("ERC20", address, mainnet=True) + symbol, decimals = await rp.multicall( + [contract.functions.symbol(), contract.functions.decimals()] + ) + return cls(address, symbol, decimals) def __str__(self) -> str: return self.symbol @@ -599,46 +685,59 @@ def __repr__(self) -> str: class DEX(Exchange, ABC): class LiquidityPool(ABC): @abstractmethod - def get_price(self) -> float: + async def get_price(self) -> float: pass @abstractmethod - def get_normalized_price(self) -> float: + async def get_normalized_price(self) -> float: pass @abstractmethod - def get_liquidity(self) -> Optional[Liquidity]: + async def get_liquidity(self) -> Liquidity | None: pass - def __init__(self, pools: list[LiquidityPool]): + def __init__(self, pools: Sequence[LiquidityPool]): self.pools = pools - def get_liquidity(self) -> dict[LiquidityPool, Liquidity]: + async def get_liquidity(self) -> dict[LiquidityPool, Liquidity]: pools = {} for pool in self.pools: - if liq := pool.get_liquidity(): + if liq := await pool.get_liquidity(): pools[pool] = liq return pools class BalancerV2(DEX): class WeightedPool(DEX.LiquidityPool): - def __init__(self, pool_id: HexStr): + def __init__( + self, pool_id: HexStr, vault, token_0: ERC20Token, token_1: ERC20Token + ): self.id = pool_id - self.vault = rp.get_contract_by_name("BalancerVault", mainnet=True) - tokens = self.vault.functions.getPoolTokens(self.id).call()[0] - self.token_0 = ERC20Token(tokens[0]) - self.token_1 = ERC20Token(tokens[1]) - - def get_price(self) -> float: - balances = self.vault.functions.getPoolTokens(self.id).call()[1] + self.vault = vault + self.token_0 = token_0 + self.token_1 = token_1 + + @classmethod + async def create(cls, pool_id: HexStr) -> "BalancerV2.WeightedPool": + vault = await rp.get_contract_by_name("BalancerVault", mainnet=True) + tokens = (await vault.functions.getPoolTokens(pool_id).call())[0] + token_0 = await ERC20Token.create(tokens[0]) + token_1 = await ERC20Token.create(tokens[1]) + return cls(pool_id, vault, token_0, token_1) + + async def get_price(self) -> float: + balances = (await self.vault.functions.getPoolTokens(self.id).call())[1] return balances[1] / balances[0] if (balances[0] > 0) else 0 - def get_normalized_price(self) -> float: - return self.get_price() * 10 ** (self.token_0.decimals - self.token_1.decimals) + async def get_normalized_price(self) -> float: + return await self.get_price() * 10 ** ( + self.token_0.decimals - self.token_1.decimals + ) - def get_liquidity(self) -> Optional[Liquidity]: - balance_0, balance_1 = self.vault.functions.getPoolTokens(self.id).call()[1] + async def get_liquidity(self) -> Liquidity | None: + balance_0, balance_1 = ( + await self.vault.functions.getPoolTokens(self.id).call() + )[1] if (balance_0 == 0) or (balance_1 == 0): log.warning("Empty token balances") return None @@ -650,7 +749,7 @@ def get_liquidity(self) -> Optional[Liquidity]: def depth_at(_price: float) -> float: invariant = balance_0 * balance_1 new_balance_0 = math.sqrt(_price * invariant / balance_norm) - return abs(new_balance_0 - balance_0) / (10 ** self.token_0.decimals) + return abs(new_balance_0 - balance_0) / (10**self.token_0.decimals) return Liquidity(price, depth_at) @@ -672,19 +771,43 @@ class UniswapV3(DEX): MAX_TICK = 887_272 @staticmethod - def tick_to_price(tick: int) -> float: - return 1.0001 ** tick + def tick_to_price(tick: float) -> float: + return 1.0001**tick @staticmethod def price_to_tick(price: float) -> float: return math.log(price, 1.0001) class Pool(DEX.LiquidityPool): - def __init__(self, pool_address: ChecksumAddress): - self.contract = rp.assemble_contract("UniswapV3Pool", pool_address, mainnet=True) - self.tick_spacing: int = self.contract.functions.tickSpacing().call() - self.token_0 = ERC20Token(self.contract.functions.token0().call()) - self.token_1 = ERC20Token(self.contract.functions.token1().call()) + def __init__( + self, + pool_address: ChecksumAddress, + contract, + tick_spacing: int, + token_0: ERC20Token, + token_1: ERC20Token, + ): + self.pool_address = pool_address + self.contract = contract + self.tick_spacing = tick_spacing + self.token_0 = token_0 + self.token_1 = token_1 + + @classmethod + async def create(cls, pool_address: ChecksumAddress) -> "UniswapV3.Pool": + contract = await rp.assemble_contract( + "UniswapV3Pool", pool_address, mainnet=True + ) + tick_spacing, token_0_addr, token_1_addr = await rp.multicall( + [ + contract.functions.tickSpacing(), + contract.functions.token0(), + contract.functions.token1(), + ] + ) + token_0 = await ERC20Token.create(token_0_addr) + token_1 = await ERC20Token.create(token_1_addr) + return cls(pool_address, contract, tick_spacing, token_0, token_1) def tick_to_word_and_bit(self, tick: int) -> tuple[int, int]: compressed = int(tick // self.tick_spacing) @@ -695,25 +818,22 @@ def tick_to_word_and_bit(self, tick: int) -> tuple[int, int]: bit_position = compressed % UniswapV3.TICK_WORD_SIZE return word_position, bit_position - def get_ticks_net_liquidity(self, ticks: list[int]) -> dict[int, int]: - return dict(zip(ticks, [ - res.results[1] for res in rp.multicall.aggregate( - [self.contract.functions.ticks(tick) for tick in ticks], - ).results - ])) + async def get_ticks_net_liquidity(self, ticks: list[int]) -> dict[int, int]: + results = await rp.multicall( + [self.contract.functions.ticks(tick) for tick in ticks] + ) + return dict(zip(ticks, [r[1] for r in results], strict=False)) - def get_initialized_ticks(self, current_tick: int) -> list[int]: + async def get_initialized_ticks(self, current_tick: int) -> list[int]: ticks = [] active_word, b = self.tick_to_word_and_bit(current_tick) word_range = list(range(active_word - 5, active_word + 5)) - bitmaps = [ - res.results[0] for res in rp.multicall.aggregate( - [self.contract.functions.tickBitmap(word) for word in word_range], - ).results - ] + bitmaps = await rp.multicall( + [self.contract.functions.tickBitmap(word) for word in word_range] + ) - for word, tick_bitmap in zip(word_range, bitmaps): + for word, tick_bitmap in zip(word_range, bitmaps, strict=False): if not tick_bitmap: continue @@ -724,32 +844,36 @@ def get_initialized_ticks(self, current_tick: int) -> list[int]: return ticks - def liquidity_to_tokens(self, liquidity: int, tick_lower: int, tick_upper: int) -> tuple[float, float]: + def liquidity_to_tokens( + self, liquidity: float, tick_lower: float, tick_upper: float + ) -> tuple[float, float]: sqrtp_lower = math.sqrt(UniswapV3.tick_to_price(tick_lower)) sqrtp_upper = math.sqrt(UniswapV3.tick_to_price(tick_upper)) delta_x = (1 / sqrtp_lower - 1 / sqrtp_upper) * liquidity delta_y = (sqrtp_upper - sqrtp_lower) * liquidity - balance_0 = float(delta_x / (10 ** self.token_0.decimals)) - balance_1 = float(delta_y / (10 ** self.token_1.decimals)) + balance_0 = float(delta_x / (10**self.token_0.decimals)) + balance_1 = float(delta_y / (10**self.token_1.decimals)) return balance_0, balance_1 - def get_price(self) -> float: - sqrt96x = self.contract.functions.slot0().call()[0] - return (sqrt96x ** 2) / (2 ** 192) + async def get_price(self) -> float: + sqrt96x = (await self.contract.functions.slot0().call())[0] + return (sqrt96x**2) / (2**192) - def get_normalized_price(self) -> float: - return self.get_price() * 10 ** (self.token_0.decimals - self.token_1.decimals) + async def get_normalized_price(self) -> float: + return await self.get_price() * 10 ** ( + self.token_0.decimals - self.token_1.decimals + ) - def get_liquidity(self) -> Optional[Liquidity]: - price = self.get_price() - initial_liquidity = self.contract.functions.liquidity().call() + async def get_liquidity(self) -> Liquidity | None: + price = await self.get_price() + initial_liquidity = await self.contract.functions.liquidity().call() calculated_tick = UniswapV3.price_to_tick(price) current_tick = int(calculated_tick) - ticks = self.get_initialized_ticks(current_tick) + ticks = await self.get_initialized_ticks(current_tick) if not ticks: log.warning("No liquidity found") @@ -757,21 +881,27 @@ def get_liquidity(self) -> Optional[Liquidity]: log.debug(f"Found {len(ticks)} initialized ticks!") - def get_cumulative_liquidity(_ticks: list[int]) -> list[float]: - cumulative_liquidity = 0 + async def get_cumulative_liquidity(_ticks: list[int]) -> list[float]: + cumulative_liquidity: float = 0 last_tick = calculated_tick active_liquidity = initial_liquidity - net_liquidity: dict[int, int] = self.get_ticks_net_liquidity(_ticks) + net_liquidity: dict[int, int] = await self.get_ticks_net_liquidity( + _ticks + ) liquidity = [] # assume liquidity in token 0 for now for tick in _ticks: if tick > last_tick: - liq_0, _ = self.liquidity_to_tokens(active_liquidity, last_tick, tick) + liq_0, _ = self.liquidity_to_tokens( + active_liquidity, last_tick, tick + ) active_liquidity += net_liquidity[tick] else: - liq_0, _ = self.liquidity_to_tokens(active_liquidity, tick, last_tick) + liq_0, _ = self.liquidity_to_tokens( + active_liquidity, tick, last_tick + ) active_liquidity -= net_liquidity[tick] cumulative_liquidity += liq_0 @@ -780,17 +910,20 @@ def get_cumulative_liquidity(_ticks: list[int]) -> list[float]: return liquidity - ask_ticks = [t for t in reversed(ticks) if t <= current_tick] + [UniswapV3.MIN_TICK] - ask_liquidity = [0] + get_cumulative_liquidity(ask_ticks) - ask_ticks = [calculated_tick] + ask_ticks + _ask_ticks = [t for t in reversed(ticks) if t <= current_tick] + [ + UniswapV3.MIN_TICK + ] + ask_liquidity = [0.0] + await get_cumulative_liquidity(_ask_ticks) + ask_ticks: list[int | float] = [calculated_tick, *_ask_ticks] - bid_ticks = [t for t in ticks if t > current_tick] + [UniswapV3.MAX_TICK] - bid_liquidity = [0] + get_cumulative_liquidity(bid_ticks) - bid_ticks = [calculated_tick] + bid_ticks + _bid_ticks = [t for t in ticks if t > current_tick] + [UniswapV3.MAX_TICK] + bid_liquidity = [0.0] + await get_cumulative_liquidity(_bid_ticks) + bid_ticks: list[int | float] = [calculated_tick, *_bid_ticks] balance_norm = 10 ** (self.token_1.decimals - self.token_0.decimals) def depth_at(_price: float) -> float: + tick: float if _price <= 0: tick = UniswapV3.MAX_TICK else: @@ -808,15 +941,22 @@ def depth_at(_price: float) -> float: if i >= len(liquidity_levels): return liquidity_levels[-1] - range_share = abs(tick - liq_ticks[i - 1]) / abs(liq_ticks[i] - liq_ticks[i - 1]) + range_share = abs(tick - liq_ticks[i - 1]) / abs( + liq_ticks[i] - liq_ticks[i - 1] + ) range_liquidity = abs(liquidity_levels[i] - liquidity_levels[i - 1]) # linear interpolation should be fine since ticks are exponential return liquidity_levels[i - 1] + range_share * range_liquidity return Liquidity(balance_norm / price, depth_at) - def __init__(self, pools: list[ChecksumAddress]): - super().__init__([UniswapV3.Pool(pool) for pool in pools]) + def __init__(self, pools: list[Pool]): + super().__init__(pools) + + @classmethod + async def create(cls, pool_addresses: list[ChecksumAddress]) -> "UniswapV3": + pools = [await UniswapV3.Pool.create(addr) for addr in pool_addresses] + return cls(pools) def __str__(self) -> str: return "Uniswap" diff --git a/rocketwatch/utils/readable.py b/rocketwatch/utils/readable.py index b541e87e..09663c15 100644 --- a/rocketwatch/utils/readable.py +++ b/rocketwatch/utils/readable.py @@ -3,10 +3,10 @@ import json import zlib -from colorama import Style, Fore +from colorama import Fore, Style import utils.solidity as units -from utils.cfg import cfg +from utils.config import cfg from utils.shared_w3 import bacon @@ -14,7 +14,7 @@ def prettify_json_string(data): return json.dumps(json.loads(data), indent=4) -def decode_abi(compressed_string): +def decode_abi(compressed_string: str) -> str: decompress = zlib.decompressobj(15) data = base64.b64decode(compressed_string) inflated = decompress.decompress(data) @@ -22,76 +22,89 @@ def decode_abi(compressed_string): return inflated.decode("ascii") -def uptime(time, highres= False): +def pretty_time(time: int | float) -> str: parts = [] - days, time = time // units.days, time % units.days + days, time = divmod(int(time), units.days) if days: - parts.append('%d day%s' % (days, 's' if days != 1 else '')) + parts.append(f"{days} day{'s' if days != 1 else ''}") - hours, time = time // units.hours, time % units.hours + hours, time = divmod(time, units.hours) if hours: - parts.append('%d hour%s' % (hours, 's' if hours != 1 else '')) + parts.append(f"{hours} hour{'s' if hours != 1 else ''}") - minutes, time = time // units.minutes, time % units.minutes + minutes, time = divmod(time, units.minutes) if minutes: - parts.append('%d minute%s' % (minutes, 's' if minutes != 1 else '')) + parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") if time or not parts: - parts.append('%.2f seconds' % time) + parts.append(f"{time:.0f} seconds") - return " ".join(parts[:2] if not highres else parts) + return " ".join(parts[:2]) def s_hex(string): return string[:10] -def cl_explorer_url(target, name=None): +async def cl_explorer_url(target, name=None): # if name is none, and it has the correct length for a validator pubkey, try to lookup the validator index if not name and isinstance(target, str) and len(target) == 98: with contextlib.suppress(Exception): - if v := bacon.get_validator(target)["data"]["index"]: + if v := (await bacon.get_validator(target))["data"]["index"]: name = f"#{v}" if not name and isinstance(target, str): name = s_hex(target) if not name: name = target - url = cfg["consensus_layer.explorer"] + url = cfg.consensus_layer.explorer return f"[{name}]({url}/validator/{target})" def advanced_tnx_url(tx_hash): - chain = cfg["rocketpool.chain"] - if chain not in ["mainnet"]: - return "" - return f"[[A]](https://ethtx.info/{chain}/{tx_hash})" + return "" def render_tree_legacy(data: dict, name: str) -> str: - # remove empty states - data = {k: v for k, v in data.items() if v} - strings = [] - values = [] - for i, (state, substates) in enumerate(data.items()): - c = sum(substates.values()) - l = "├" if i != len(data) - 1 else "└" - strings.append(f" {l}{state.title()}: ") - values.append(c) - l = "│" if i != len(data) - 1 else " " - for j, (substate, count) in enumerate(substates.items()): - sl = "├" if j != len(substates) - 1 else "└" - strings.append(f" {l} {sl}{substate.title()}: ") - values.append(count) + def render_branch(_data: dict[str, dict | int]) -> tuple[list, list, int]: + _strings = [] + _values = [] + count = 0 + + _data = {k: v for k, v in _data.items() if v} + for i, (state, sub_data) in enumerate(_data.items()): + link = "├" if (i != len(_data) - 1) else "└" + _strings.append(f" {link}{state.title()}: ") + + if isinstance(sub_data, dict): + sub_strings, sub_values, sub_count = render_branch(sub_data) + sub_link = " │" if (i != len(_data) - 1) else " " + _strings.extend([sub_link + s for s in sub_strings]) + _values.append(sub_count) + _values.extend(sub_values) + count += sub_count + elif isinstance(sub_data, int): + _values.append(sub_data) + count += sub_data + + return _strings, _values, count + + strings, values, tree_sum = render_branch(data) + strings.insert(0, f"{name}:") + values.insert(0, tree_sum) + + fmt_values = [f"{v:,}" for v in values] + # longest string offset max_left_len = max(len(s) for s in strings) - max_right_len = max(len(str(v)) for v in values) - # right align all values - for i, v in enumerate(values): - strings[i] = strings[i].ljust(max_left_len) + str(v).rjust(max_right_len) - description = f"{name}:\n" - description += "\n".join(strings) - return description + max_right_len = max(len(v) for v in fmt_values) + + lines = [] + for s, v in zip(strings, fmt_values, strict=False): + # right align all values + lines.append(s.ljust(max_left_len) + v.rjust(max_right_len)) + + return "\n".join(lines) def render_branch(k, v, prefix, current_depth=0, max_depth=0, reverse=False, m_prev=""): @@ -112,29 +125,76 @@ def render_branch(k, v, prefix, current_depth=0, max_depth=0, reverse=False, m_p p = p[::-1] p += "├─" if i != len(v) - 1 else f"{m}─" # last connection if not reverse: - a = list(render_branch(sk, sv, p, current_depth + 1, max_depth=max_depth, reverse=False, m_prev=m)) + a + a = ( + list( + render_branch( + sk, + sv, + p, + current_depth + 1, + max_depth=max_depth, + reverse=False, + m_prev=m, + ) + ) + + a + ) else: - a.extend(render_branch(sk, sv, p, current_depth + 1, max_depth=max_depth, reverse=False, m_prev=m)) + a.extend( + render_branch( + sk, + sv, + p, + current_depth + 1, + max_depth=max_depth, + reverse=False, + m_prev=m, + ) + ) return a def render_tree(data: dict, name: str, max_depth: int = 0) -> str: # remove empty states data = {k: v for k, v in data.items() if v} - lines, values, depths = map(list, zip(*list(reversed(render_branch(name, data, "", max_depth=max_depth, reverse=True))))) - max_right_len, max_left_len = [], [] + lines, values, depths = map( + list, + zip( + *list( + reversed( + render_branch(name, data, "", max_depth=max_depth, reverse=True) + ) + ), + strict=False, + ), + ) # longest string offset per depth - max_left_len = max(max(len(s) for s, d in zip(lines, depths) if d == depth) for depth in set(depths)) + max_left_len: int = max( + max(len(s) for s, d in zip(lines, depths, strict=False) if d == depth) + for depth in set(depths) + ) # same for right - max_right_len = max(max(len(str(v)) for v, d in zip(values, depths) if d == depth) for depth in set(depths)) + max_right_len: int = max( + max(len(str(v)) for v, d in zip(values, depths, strict=False) if d == depth) + for depth in set(depths) + ) max_right_len += 2 - COLORS = [Style.BRIGHT, Style.BRIGHT, Fore.RESET, Fore.BLACK, Fore.BLACK, Fore.BLACK] - for i, (v, d) in enumerate(zip(values, depths)): + COLORS = [ + Style.BRIGHT, + Style.BRIGHT, + Fore.RESET, + Fore.BLACK, + Fore.BLACK, + Fore.BLACK, + ] + for i, (v, d) in enumerate(zip(values, depths, strict=False)): _v = v _v = f"{COLORS[d]}{v}{Style.RESET_ALL}" - lines[i] = f"{lines[i].ljust(max_left_len, ' ')}{' ' * (max_right_len - len(str(v)))}{_v}" + lines[i] = ( + f"{lines[i].ljust(max_left_len, ' ')}{' ' * (max_right_len - len(str(v)))}{_v}" + ) # replace all spaces with non-breaking spaces - lines = [l.replace(" ", " ") for l in lines] + lines = [line.replace(" ", "\u00a0") for line in lines] return "\n".join(lines) diff --git a/rocketwatch/utils/retry.py b/rocketwatch/utils/retry.py index 7056ab9f..a39339e1 100644 --- a/rocketwatch/utils/retry.py +++ b/rocketwatch/utils/retry.py @@ -1,8 +1,9 @@ -from retry_async.api import ( - retry as __retry, - EXCEPTIONS -) -from typing import Callable, Any +import inspect +from collections.abc import Callable +from typing import Any + +from retry_async.api import EXCEPTIONS +from retry_async.api import retry as __retry def retry( @@ -10,17 +11,17 @@ def retry( *, tries: int = -1, delay: float = 0, - max_delay: float = None, - backoff: float = 1 + max_delay: float | None = None, + backoff: float = 1, ) -> Callable[..., Any]: - return __retry(exceptions, is_async=False, tries=tries, delay=delay, max_delay=max_delay, backoff=backoff) + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + return __retry( + exceptions, + is_async=inspect.iscoroutinefunction(func), + tries=tries, + delay=delay, + max_delay=max_delay, # pyright: ignore[reportArgumentType] + backoff=backoff, + )(func) -def retry_async( - exceptions: EXCEPTIONS = Exception, - *, - tries: int = -1, - delay: float = 0, - max_delay: float = None, - backoff: float = 1 -) -> Callable[..., Any]: - return __retry(exceptions, is_async=True, tries=tries, delay=delay, max_delay=max_delay, backoff=backoff) + return decorator diff --git a/rocketwatch/utils/rocketpool.py b/rocketwatch/utils/rocketpool.py index 8a47c614..d6f03f3f 100644 --- a/rocketwatch/utils/rocketpool.py +++ b/rocketwatch/utils/rocketpool.py @@ -1,129 +1,190 @@ import logging import os +from collections.abc import Sequence from pathlib import Path -from typing import Optional +from typing import Any, cast from bidict import bidict -from cachetools import cached, FIFOCache -from cachetools.func import ttl_cache -from multicall import Call, Multicall -from multicall.constants import MULTICALL3_ADDRESSES +from cachetools import FIFOCache +from eth_abi import abi +from eth_typing import BlockIdentifier, ChecksumAddress +from web3.constants import ADDRESS_ZERO +from web3.contract import AsyncContract +from web3.contract.async_contract import AsyncContractFunction from web3.exceptions import ContractLogicError -from web3_multicall import Multicall as Web3Multicall from utils import solidity -from utils.cfg import cfg +from utils.config import cfg from utils.readable import decode_abi -from utils.shared_w3 import w3, mainnet_w3, historical_w3 -from utils.time_debug import timerun_async +from utils.shared_w3 import w3, w3_archive, w3_mainnet -log = logging.getLogger("rocketpool") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.rocketpool") class NoAddressFound(Exception): pass + class RocketPool: - ADDRESS_CACHE = FIFOCache(maxsize=2048) - ABI_CACHE = FIFOCache(maxsize=2048) - CONTRACT_CACHE = FIFOCache(maxsize=2048) + ADDRESS_CACHE: FIFOCache[str, ChecksumAddress] = FIFOCache(maxsize=2048) + ABI_CACHE: FIFOCache[str, str] = FIFOCache(maxsize=2048) + CONTRACT_CACHE: FIFOCache[tuple, AsyncContract] = FIFOCache(maxsize=2048) def __init__(self): - self.addresses = bidict() - self.multicall = Web3Multicall(w3.eth, MULTICALL3_ADDRESSES[w3.eth.chain_id]) - self.flush() + self.addresses: bidict[str, ChecksumAddress] = bidict() + self._multicall = None + + async def async_init(self): + await self._init_contract_addresses() - def flush(self): + async def flush(self): log.warning("FLUSHING RP CACHE") self.CONTRACT_CACHE.clear() self.ABI_CACHE.clear() self.ADDRESS_CACHE.clear() - self.addresses = bidict() - self._init_contract_addresses() + self.addresses.clear() + await self._init_contract_addresses() - def _init_contract_addresses(self) -> None: - manual_addresses = cfg["rocketpool.manual_addresses"] + async def _init_contract_addresses(self) -> None: + manual_addresses = cfg.rocketpool.manual_addresses for name, address in manual_addresses.items(): - self.addresses[name] = address + self.addresses[name] = w3.to_checksum_address(address) - self.addresses["multicall3"] = self.multicall.address + self._multicall = await self.get_contract_by_name("multicall3") log.info("Indexing Rocket Pool contracts...") - # generate list of all file names with the .sol extension from the rocketpool submodule - for path in Path("contracts/rocketpool/contracts/contract").rglob('*.sol'): - # append to list but ensure that the first character is lowercase + for path in Path("contracts/rocketpool/contracts/contract").rglob("*.sol"): file_name = path.stem contract = file_name[0].lower() + file_name[1:] try: - self.get_address_by_name(contract) + await self.get_address_by_name(contract) except Exception: log.warning(f"Skipping {contract} in function list generation") continue try: cs_dir, cs_prefix = "ConstellationDirectory", "Constellation" - self.addresses |= { - f"{cs_prefix}.SuperNodeAccount": self.call(f"{cs_dir}.getSuperNodeAddress"), - f"{cs_prefix}.OperatorDistributor": self.call(f"{cs_dir}.getOperatorDistributorAddress"), - f"{cs_prefix}.Whitelist": self.call(f"{cs_dir}.getWhitelistAddress"), - f"{cs_prefix}.ETHVault": self.call(f"{cs_dir}.getWETHVaultAddress"), - f"{cs_prefix}.RPLVault": self.call(f"{cs_dir}.getRPLVaultAddress"), - "WETH": self.call(f"{cs_dir}.getWETHAddress") - } + self.addresses.update( + { + f"{cs_prefix}.SuperNodeAccount": await self.call( + f"{cs_dir}.getSuperNodeAddress" + ), + f"{cs_prefix}.OperatorDistributor": await self.call( + f"{cs_dir}.getOperatorDistributorAddress" + ), + f"{cs_prefix}.Whitelist": await self.call( + f"{cs_dir}.getWhitelistAddress" + ), + f"{cs_prefix}.ETHVault": await self.call( + f"{cs_dir}.getWETHVaultAddress" + ), + f"{cs_prefix}.RPLVault": await self.call( + f"{cs_dir}.getRPLVaultAddress" + ), + "WETH": await self.call(f"{cs_dir}.getWETHAddress"), + } + ) except NoAddressFound: log.warning("Failed to find address for Constellation contracts") @staticmethod - def seth_sig(abi, function_name): - # also handle tuple outputs, so `example(unit256)((unit256,unit256))` for example - for item in abi: - if item.get("name") == function_name: - inputs = ','.join([i['type'] for i in item['inputs']]) - outputs = [] - for o in item['outputs']: - if o['type'] == 'tuple': - outputs.append(f"({','.join([i['type'] for i in o['components']])})") - else: - outputs.append(o['type']) - outputs = ','.join(outputs) - return f"{function_name}({inputs})({outputs})" - raise Exception(f"Function {function_name} not found in ABI") - - @timerun_async - async def multicall2(self, calls: list[Call], require_success=True): - return await Multicall(calls, _w3=w3, gas_limit=50_000_000, require_success=require_success) - - @cached(cache=ADDRESS_CACHE) - def get_address_by_name(self, name): - # manual overwrite at init + def _abi_type_str(output: dict) -> str: + """Convert a single ABI output entry to an eth_abi type string, handling tuples.""" + t = output["type"] + if "tuple" in t: + inner = ",".join(RocketPool._abi_type_str(c) for c in output["components"]) + suffix = t[5:] # captures "", "[]", "[N]", etc. + return f"({inner}){suffix}" + return t + + @staticmethod + def _decode_fn_output(fn, data: bytes) -> Any: + """Decode raw ABI output bytes for a ContractFunction.""" + outputs = fn.abi["outputs"] + if not outputs: + return None + types = [RocketPool._abi_type_str(o) for o in outputs] + decoded = abi.decode(types, data) + return decoded[0] if len(decoded) == 1 else decoded + + CallInput = AsyncContractFunction | tuple[AsyncContractFunction, bool] + + @staticmethod + def _normalize_calls( + calls: Sequence[CallInput], default_require_success: bool + ) -> tuple[list[AsyncContractFunction], list[bool]]: + """Normalize calls to (fn, allow_failure) pairs. Each call may be a + plain AsyncContractFunction or an (fn, require_success) tuple.""" + fns: list[AsyncContractFunction] = [] + flags: list[bool] = [] + for call in calls: + if isinstance(call, tuple): + fn, req = call + else: + fn, req = call, default_require_success + fns.append(fn) + flags.append(not req) + return fns, flags + + async def multicall( + self, + calls: Sequence[CallInput], + require_success: bool = True, + block: BlockIdentifier = "latest", + ) -> list[Any]: + """Multicall accepting AsyncContractFunction objects or (fn, require_success) tuples.""" + fns, flags = self._normalize_calls(calls, require_success) + encoded = [ + (fn.address, af, fn._encode_transaction_data()) + for fn, af in zip(fns, flags, strict=False) + ] + assert self._multicall is not None + results = await self._multicall.functions.aggregate3(encoded).call( + block_identifier=block + ) + return [ + RocketPool._decode_fn_output(fns[i], data) if success else None + for i, (success, data) in enumerate(results) + ] + + async def get_address_by_name(self, name: str) -> ChecksumAddress: + if name in self.ADDRESS_CACHE: + return self.ADDRESS_CACHE[name] if name in self.addresses: + self.ADDRESS_CACHE[name] = self.addresses[name] return self.addresses[name] - return self.uncached_get_address_by_name(name) + address = await self.uncached_get_address_by_name(name) + self.ADDRESS_CACHE[name] = address + return address - def uncached_get_address_by_name(self, name, block="latest"): + async def uncached_get_address_by_name( + self, name: str, block: BlockIdentifier = "latest" + ) -> ChecksumAddress: log.debug(f"Retrieving address for {name} Contract") - sha3 = w3.soliditySha3(["string", "string"], ["contract.address", name]) - address = self.get_contract_by_name("rocketStorage", historical=block != "latest").functions.getAddress(sha3).call(block_identifier=block) - if not w3.toInt(hexstr=address): + sha3 = w3.solidity_keccak(["string", "string"], ["contract.address", name]) + storage = await self.get_contract_by_name( + "rocketStorage", historical=block != "latest" + ) + address = await storage.functions.getAddress(sha3).call(block_identifier=block) + if address == ADDRESS_ZERO: raise NoAddressFound(f"No address found for {name} Contract") self.addresses[name] = address log.debug(f"Retrieved address for {name} Contract: {address}") return address @staticmethod - def get_revert_reason(tnx): + async def get_revert_reason(tnx): try: - w3.eth.call( + await w3.eth.call( { - "from" : tnx["from"], - "to" : tnx["to"], - "data" : tnx["input"], - "gas" : tnx["gas"], + "from": tnx["from"], + "to": tnx["to"], + "data": tnx["input"], + "gas": tnx["gas"], "gasPrice": tnx["gasPrice"], - "value" : tnx["value"] + "value": tnx["value"], }, - block_identifier=tnx.blockNumber + block_identifier=tnx.blockNumber, ) except ContractLogicError as err: log.debug(f"Transaction: {tnx.hash} ContractLogicError: {err}") @@ -138,20 +199,47 @@ def get_revert_reason(tnx): else: return None - @cached(cache=ABI_CACHE) - def get_abi_by_name(self, name): - return self.uncached_get_abi_by_name(name) - - def uncached_get_abi_by_name(self, name): - log.debug(f"Retrieving abi for {name} Contract") - sha3 = w3.soliditySha3(["string", "string"], ["contract.abi", name]) - compressed_string = self.get_contract_by_name("rocketStorage").functions.getString(sha3).call() + async def get_string(self, key: str) -> str: + sha3 = w3.solidity_keccak(["string"], [key]) + storage = await self.get_contract_by_name("rocketStorage") + return await storage.functions.getString(sha3).call() + + async def get_uint(self, key: str) -> int: + sha3 = w3.solidity_keccak(["string"], [key]) + storage = await self.get_contract_by_name("rocketStorage") + return await storage.functions.getUint(sha3).call() + + async def get_protocol_version(self) -> tuple: + version_string = await self.get_string("protocol.version") + return tuple(map(int, version_string.split("."))) + + async def get_abi_by_name(self, name) -> str: + if name in self.ABI_CACHE: + return self.ABI_CACHE[name] + abi = await self.uncached_get_abi_by_name(name) + self.ABI_CACHE[name] = abi + return abi + + async def uncached_get_abi_by_name(self, name) -> str: + log.debug(f"Retrieving abi for {name} contract") + sha3 = w3.solidity_keccak(["string", "string"], ["contract.abi", name]) + storage = await self.get_contract_by_name("rocketStorage") + compressed_string = await storage.functions.getString(sha3).call() if not compressed_string: - raise Exception(f"No abi found for {name} Contract") + raise Exception(f"No abi found for {name} contract") return decode_abi(compressed_string) - @cached(cache=CONTRACT_CACHE) - def assemble_contract(self, name, address=None, historical=False, mainnet=False): + async def assemble_contract( + self, + name: str, + address: ChecksumAddress | None = None, + historical: bool = False, + mainnet: bool = False, + ) -> AsyncContract: + cache_key = (name, address, historical, mainnet) + if cache_key in self.CONTRACT_CACHE: + return self.CONTRACT_CACHE[cache_key] + if name.startswith("Constellation."): short_name = name.removeprefix("Constellation.") abi_path = f"./contracts/constellation/{short_name}.abi.json" @@ -159,73 +247,121 @@ def assemble_contract(self, name, address=None, historical=False, mainnet=False) abi_path = f"./contracts/{name}.abi.json" if os.path.exists(abi_path): - with open(abi_path, "r") as f: + with open(abi_path) as f: abi = f.read() else: - abi = self.get_abi_by_name(name) + abi = await self.get_abi_by_name(name) if mainnet: - return mainnet_w3.eth.contract(address=address, abi=abi) - if historical: - return historical_w3.eth.contract(address=address, abi=abi) - return w3.eth.contract(address=address, abi=abi) + contract = w3_mainnet.eth.contract(address=address, abi=abi) + elif historical: + contract = w3_archive.eth.contract(address=address, abi=abi) + else: + contract = w3.eth.contract(address=address, abi=abi) - def get_name_by_address(self, address): - return self.addresses.inverse.get(address, None) + contract = cast(AsyncContract, contract) + self.CONTRACT_CACHE[cache_key] = contract + return contract - def get_contract_by_name(self, name, historical=False, mainnet=False): - address = self.get_address_by_name(name) - return self.assemble_contract(name, address, historical=historical, mainnet=mainnet) + def get_name_by_address(self, address: ChecksumAddress) -> str | None: + return self.addresses.inverse.get(address, None) - def get_contract_by_address(self, address): + async def get_contract_by_name( + self, name: str, historical: bool = False, mainnet: bool = False + ) -> AsyncContract: + address = await self.get_address_by_name(name) + return await self.assemble_contract( + name, address, historical=historical, mainnet=mainnet + ) + + async def get_contract_by_address( + self, address: ChecksumAddress + ) -> AsyncContract | None: """ **WARNING**: only call after contract has been previously retrieved using its name """ - name = self.get_name_by_address(address) - return self.assemble_contract(name, address) + if not (name := self.get_name_by_address(address)): + return None + return await self.assemble_contract(name, address) - def estimate_gas_for_call(self, path, *args, block="latest"): - log.debug(f"Estimating gas for {path} (block={block})") + async def estimate_gas_for_call( + self, path: str, *args, block: BlockIdentifier = "latest" + ) -> int: + log.debug(f"Estimating gas for {path} (block={block!r})") name, function = path.rsplit(".", 1) - contract = self.get_contract_by_name(name) - return contract.functions[function](*args).estimateGas({"gas": 2 ** 32}, - block_identifier=block) - - def get_function(self, path, *args, historical=False, address=None, mainnet=False): + contract = await self.get_contract_by_name(name) + return await contract.functions[function](*args).estimate_gas( + {"gas": 2**32}, block_identifier=block + ) + + async def get_function( + self, + path: str, + *args, + historical: bool = False, + address: ChecksumAddress | None = None, + mainnet: bool = False, + ) -> AsyncContractFunction: name, function = path.rsplit(".", 1) if not address: - address = self.get_address_by_name(name) - contract = self.assemble_contract(name, address, historical, mainnet) + address = await self.get_address_by_name(name) + contract = await self.assemble_contract(name, address, historical, mainnet) + args = tuple( + w3.to_checksum_address(a) if isinstance(a, str) and w3.is_address(a) else a + for a in args + ) return contract.functions[function](*args) - def call(self, path, *args, block="latest", address=None, mainnet=False): - log.debug(f"Calling {path} (block={block})") - return self.get_function(path, *args, historical=block != "latest", address=address, mainnet=mainnet).call(block_identifier=block) - - def get_annual_rpl_inflation(self): - inflation_per_interval = solidity.to_float(self.call("rocketTokenRPL.getInflationIntervalRate")) + async def call( + self, + path: str, + *args, + block: BlockIdentifier = "latest", + address: ChecksumAddress | None = None, + mainnet: bool = False, + ) -> Any: + log.debug(f"Calling {path} (block={block!r})") + fn = await self.get_function( + path, *args, historical=block != "latest", address=address, mainnet=mainnet + ) + return await fn.call(block_identifier=block) + + async def get_annual_rpl_inflation(self) -> float: + inflation_per_interval: float = solidity.to_float( + await self.call("rocketTokenRPL.getInflationIntervalRate") + ) if not inflation_per_interval: return 0 - seconds_per_interval = self.call("rocketTokenRPL.getInflationIntervalTime") + seconds_per_interval: int = await self.call( + "rocketTokenRPL.getInflationIntervalTime" + ) intervals_per_year = solidity.years / seconds_per_interval - return (inflation_per_interval ** intervals_per_year) - 1 + return (inflation_per_interval**intervals_per_year) - 1 + + async def is_node(self, address: ChecksumAddress) -> bool: + return await self.call("rocketNodeManager.getNodeExists", address) + + async def is_minipool(self, address: ChecksumAddress) -> bool: + return await self.call("rocketMinipoolManager.getMinipoolExists", address) - def get_percentage_rpl_swapped(self): - value = solidity.to_float(self.call("rocketTokenRPL.totalSwappedRPL")) - percentage = (value / 18_000_000) * 100 - return round(percentage, 2) + async def is_megapool(self, address: ChecksumAddress) -> bool: + sha3 = w3.solidity_keccak(["string", "address"], ["megapool.exists", address]) + storage = await self.get_contract_by_name("rocketStorage") + return await storage.functions.getBool(sha3).call() - @ttl_cache(ttl=60) - def get_eth_usdc_price(self) -> float: + async def get_eth_usdc_price(self) -> float: from utils.liquidity import UniswapV3 - pool_address = self.get_address_by_name("UniV3_USDC_ETH") - return 1 / UniswapV3.Pool(pool_address).get_normalized_price() - @ttl_cache(ttl=60) - def get_reth_eth_price(self) -> Optional[float]: + pool_address = await self.get_address_by_name("UniV3_USDC_ETH") + pool = await UniswapV3.Pool.create(pool_address) + return 1 / await pool.get_normalized_price() + + async def get_reth_eth_price(self) -> float: from utils.liquidity import UniswapV3 - pool_address = self.get_address_by_name("UniV3_rETH_ETH") - return UniswapV3.Pool(pool_address).get_normalized_price() + + pool_address = await self.get_address_by_name("UniV3_rETH_ETH") + pool = await UniswapV3.Pool.create(pool_address) + return await pool.get_normalized_price() rp = RocketPool() diff --git a/rocketwatch/utils/sea_creatures.py b/rocketwatch/utils/sea_creatures.py index b242aa31..8af8559b 100644 --- a/rocketwatch/utils/sea_creatures.py +++ b/rocketwatch/utils/sea_creatures.py @@ -1,36 +1,32 @@ import contextlib + from utils import solidity -from utils.cfg import cfg from utils.rocketpool import rp from utils.shared_w3 import w3 -price_cache = { - "block" : 0, - "rpl_price" : 0, - "reth_price": 0 -} +price_cache = {"block": 0, "rpl_price": 0, "reth_price": 0} sea_creatures = { # 32 * 100: spouting whale emoji - 32 * 100: '🐳', + 32 * 100: "🐳", # 32 * 50: whale emoji - 32 * 50 : '🐋', + 32 * 50: "🐋", # 32 * 30: shark emoji - 32 * 30 : '🦈', + 32 * 30: "🦈", # 32 * 20: dolphin emoji - 32 * 20 : '🐬', + 32 * 20: "🐬", # 32 * 10: octopus emoji - 32 * 10 : '🐙', + 32 * 10: "🐙", # 32 * 5: fish emoji - 32 * 5 : '🐟', + 32 * 5: "🐟", # 32 * 2: crab emoji - 32 * 2 : '🦀', + 32 * 2: "🦀", # 32 * 1: fried shrimp emoji - 32 * 1 : '🍤', + 32 * 1: "🍤", # 5: snail emoji - 5 : '🐌', + 5: "🐌", # 1: microbe emoji - 1 : '🦠' + 1: "🦠", } @@ -40,44 +36,61 @@ def get_sea_creature_for_holdings(holdings): :param holdings: The holdings to get the sea creature for. :return: The sea creature for the given holdings. """ - # if the holdings are more than 2 times the highest sea creature, return the highest sea creature with a multiplier next to it + # if the holdings are more than 2 times the highest sea creature, + # return the highest sea creature with a multiplier next to it highest_possible_holdings = max(sea_creatures.keys()) if holdings >= 2 * highest_possible_holdings: - return sea_creatures[highest_possible_holdings] * int(holdings / highest_possible_holdings) - return next((sea_creature for holding_value, sea_creature in sea_creatures.items() if holdings >= holding_value), '') + return sea_creatures[highest_possible_holdings] * int( + holdings / highest_possible_holdings + ) + return next( + ( + sea_creature + for holding_value, sea_creature in sea_creatures.items() + if holdings >= holding_value + ), + "", + ) -def get_holding_for_address(address): - if cfg["rocketpool.chain"] != "mainnet": - return 0 - if price_cache["block"] != (b := w3.eth.blockNumber): - price_cache["rpl_price"] = solidity.to_float(rp.call("rocketNetworkPrices.getRPLPrice")) - price_cache["reth_price"] = solidity.to_float(rp.call("rocketTokenRETH.getExchangeRate")) +async def get_holding_for_address(address): + if price_cache["block"] != (b := await w3.eth.get_block_number()): + price_cache["rpl_price"] = solidity.to_float( + await rp.call("rocketNetworkPrices.getRPLPrice") + ) + price_cache["reth_price"] = solidity.to_float( + await rp.call("rocketTokenRETH.getExchangeRate") + ) price_cache["block"] = b # get their eth balance - eth_balance = solidity.to_float(w3.eth.getBalance(address)) + eth_balance = solidity.to_float(await w3.eth.get_balance(address)) # get ERC-20 token balance for this address with contextlib.suppress(Exception): - resp = rp.multicall.aggregate( - rp.get_contract_by_name(name).functions.balanceOf(address) for name in - ["rocketTokenRPL", "rocketTokenRPLFixedSupply", "rocketTokenRETH"] + rpl_contract = await rp.get_contract_by_name("rocketTokenRPL") + rplfs_contract = await rp.get_contract_by_name("rocketTokenRPLFixedSupply") + reth_contract = await rp.get_contract_by_name("rocketTokenRETH") + rpl_balance, rplfs_balance, reth_balance = await rp.multicall( + [ + rpl_contract.functions.balanceOf(address), + rplfs_contract.functions.balanceOf(address), + reth_contract.functions.balanceOf(address), + ] ) - # add their tokens to their eth balance - for token in resp.results: - contract_name = rp.get_name_by_address(token.contract_address) - if "RPL" in contract_name: - eth_balance += solidity.to_float(token.results[0]) * price_cache["rpl_price"] - if "RETH" in contract_name: - eth_balance += solidity.to_float(token.results[0]) * price_cache["reth_price"] + eth_balance += solidity.to_float(rpl_balance) * price_cache["rpl_price"] + eth_balance += solidity.to_float(rplfs_balance) * price_cache["rpl_price"] + eth_balance += solidity.to_float(reth_balance) * price_cache["reth_price"] # add eth they provided for minipools - eth_balance += solidity.to_int(rp.call("rocketNodeStaking.getNodeETHProvided", address)) + eth_balance += solidity.to_float( + await rp.call("rocketNodeStaking.getNodeETHBonded", address) + ) # add their staked RPL - staked_rpl = solidity.to_int(rp.call("rocketNodeStaking.getNodeRPLStake", address)) + staked_rpl = solidity.to_float( + await rp.call("rocketNodeStaking.getNodeStakedRPL", address) + ) eth_balance += staked_rpl * price_cache["rpl_price"] return eth_balance -def get_sea_creature_for_address(address): - # return the sea creature for the given holdings - return get_sea_creature_for_holdings(get_holding_for_address(address)) +async def get_sea_creature_for_address(address): + return get_sea_creature_for_holdings(await get_holding_for_address(address)) diff --git a/rocketwatch/utils/shared_w3.py b/rocketwatch/utils/shared_w3.py index 89db7c35..9fe8dc35 100644 --- a/rocketwatch/utils/shared_w3.py +++ b/rocketwatch/utils/shared_w3.py @@ -1,97 +1,40 @@ -import logging -import math +from typing import Any -import circuitbreaker -import requests -from requests import HTTPError, ConnectTimeout -from web3 import Web3, HTTPProvider -from web3.beacon import Beacon as Bacon -from web3.middleware import geth_poa_middleware +from web3 import AsyncWeb3 +from web3.beacon import AsyncBeacon +from web3.providers import AsyncHTTPProvider -from utils.cfg import cfg -from utils.retry import retry +from utils.config import cfg -log = logging.getLogger("shared_w3") -log.setLevel(cfg["log_level"]) -w3 = Web3(HTTPProvider(cfg['execution_layer.endpoint.current'], request_kwargs={'timeout': 60})) -mainnet_w3 = w3 +class Bacon(AsyncBeacon): + async def get_validators_by_ids( + self, state_id: str, ids: list[int] + ) -> dict[str, Any]: + id_str = ",".join(map(str, ids)) + return await self._async_make_get_request( + f"/eth/v1/beacon/states/{state_id}/validators?id={id_str}" + ) -if cfg['rocketpool.chain'] != "mainnet": - mainnet_w3 = Web3(HTTPProvider(cfg['execution_layer.endpoint.mainnet'])) - w3.middleware_onion.inject(geth_poa_middleware, layer=0) + async def get_sync_committee(self, epoch: int) -> dict[str, Any]: + return await self._async_make_get_request( + f"/eth/v1/beacon/states/head/sync_committees?epoch={epoch}" + ) -historical_w3 = None -if "archive" in cfg['execution_layer.endpoint'].keys(): - historical_w3 = Web3(HTTPProvider(cfg['execution_layer.endpoint.archive'])) -endpoints = cfg["consensus_layer.endpoints"] -tmp = [] -exceptions = ( - HTTPError, ConnectionError, ConnectTimeout, requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) -for fallback_endpoint in reversed(endpoints): - class SuperBacon(Bacon): - def __init__( - self, - base_url: str, - session: requests.Session = requests.Session(), - ) -> None: - super().__init__(base_url, session) +def _get_web3(endpoint: str): + provider = AsyncHTTPProvider(endpoint, request_kwargs={"timeout": 60}) + return AsyncWeb3(provider) - @retry(tries=3 if tmp else 1, exceptions=exceptions, delay=0.5) - @retry(tries=5 if tmp else 1, exceptions=ValueError, delay=0.1) - @circuitbreaker.circuit(failure_threshold=2 if tmp else math.inf, - recovery_timeout=15, - expected_exception=exceptions, - fallback_function=tmp[-1].get_block if tmp else None, - name=f"get_block using {fallback_endpoint}") - def get_block(self, *args): - block_id = args[-1] - if len(args) > 1: - log.warning(f"falling back to {self.base_url} for block {block_id}") - endpoint = f"/eth/v2/beacon/blocks/{block_id}" - url = self.base_url + endpoint - response = self.session.get(url, timeout=(3.05, 20)) - if response.status_code == 404 and all(q in response.json()["message"].lower() for q in ["not", "found"]): - raise ValueError("Block does not exist") - response.raise_for_status() - return response.json() - @retry(tries=3 if tmp else 1, exceptions=exceptions, delay=0.5) - @circuitbreaker.circuit(failure_threshold=2 if tmp else math.inf, - recovery_timeout=90, - fallback_function=tmp[-1].get_validator_balances if tmp else None, - name=f"get_validator_balances using {fallback_endpoint}") - def get_validator_balances(self, *args, **kwargs): - state_id = args[-1] - if len(args) > 1: - log.warning(f"falling back to {self.base_url} for validator balances {state_id}") - endpoint = f"/eth/v1/beacon/states/{state_id}/validator_balances" - # id array if present, and is array of ints - if "ids" in kwargs and all(isinstance(i, int) for i in kwargs['ids']): - # turn to array of strings - kwargs['ids'] = [str(i) for i in kwargs['ids']] - endpoint += f"?id={','.join(kwargs['ids'])}" - url = self.base_url + endpoint - response = self.session.get(url, timeout=(5, 30)) - response.raise_for_status() - return response.json() +w3 = _get_web3(cfg.execution_layer.endpoint.current) +w3_mainnet = w3 +w3_archive = w3 - def get_validators(self, *args, **kwargs): - state_id = args[-1] - if len(args) > 1: - log.warning(f"falling back to {self.base_url} for validator balances {state_id}") - endpoint = f"/eth/v1/beacon/states/{state_id}/validators" - # id array if present, and is array of ints - if "ids" in kwargs and isinstance(kwargs["ids"], list): - # turn to array of strings - kwargs['ids'] = [str(i) for i in kwargs['ids']] - endpoint += f"?id={','.join(kwargs['ids'])}" - url = self.base_url + endpoint - response = self.session.get(url, timeout=(5, 30)) - response.raise_for_status() - return response.json() +if cfg.rocketpool.chain.lower() != "mainnet": + w3_mainnet = _get_web3(cfg.execution_layer.endpoint.mainnet) +if cfg.execution_layer.endpoint.archive is not None: + w3_archive = _get_web3(cfg.execution_layer.endpoint.archive) - tmp.append(SuperBacon(fallback_endpoint)) -bacon = tmp[-1] +bacon = Bacon(cfg.consensus_layer.endpoint) diff --git a/rocketwatch/utils/solidity.py b/rocketwatch/utils/solidity.py index 5459a6d2..096e721b 100644 --- a/rocketwatch/utils/solidity.py +++ b/rocketwatch/utils/solidity.py @@ -12,26 +12,38 @@ def to_float(n, decimals=18): - return int(n) / 10 ** decimals + return int(n) / 10**decimals def to_int(n, decimals=18): - return int(n) // 10 ** decimals + return int(n) // 10**decimals def beacon_block_to_date(block_num: int) -> int: return BEACON_START_DATE + (block_num * 12) + def date_to_beacon_block(date: int) -> int: return (date - BEACON_START_DATE) // 12 + def slot_to_beacon_day_epoch_slot(slot: int) -> tuple[int, int, int]: return slot // 32 // 225, slot // 32 % 225, slot % 32 SUBMISSION_KEYS = ( - "rewardIndex", "executionBlock", "consensusBlock", "merkleRoot", "merkleTreeCID", "intervalsPassed", "treasuryRPL", - "trustedNodeRPL", "nodeRPL", "nodeETH", "userETH") + "rewardIndex", + "executionBlock", + "consensusBlock", + "merkleRoot", + "merkleTreeCID", + "intervalsPassed", + "treasuryRPL", + "trustedNodeRPL", + "nodeRPL", + "nodeETH", + "userETH", +) def mp_state_to_str(state): diff --git a/rocketwatch/utils/status.py b/rocketwatch/utils/status.py index 9072a27a..9530d298 100644 --- a/rocketwatch/utils/status.py +++ b/rocketwatch/utils/status.py @@ -1,8 +1,12 @@ +from __future__ import annotations + from abc import abstractmethod +from typing import TYPE_CHECKING from discord.ext import commands -from rocketwatch import RocketWatch +if TYPE_CHECKING: + from rocketwatch.rocketwatch import RocketWatch from utils.embeds import Embed diff --git a/rocketwatch/utils/time_debug.py b/rocketwatch/utils/time_debug.py index 44044f20..3e37aea2 100644 --- a/rocketwatch/utils/time_debug.py +++ b/rocketwatch/utils/time_debug.py @@ -2,10 +2,7 @@ import logging import time -from utils.cfg import cfg - -log = logging.getLogger("time_debug") -log.setLevel(cfg["log_level"]) +log = logging.getLogger("rocketwatch.time_debug") def timerun(func): diff --git a/rocketwatch/utils/views.py b/rocketwatch/utils/views.py index 7fc391db..2d5b6730 100644 --- a/rocketwatch/utils/views.py +++ b/rocketwatch/utils/views.py @@ -1,45 +1,52 @@ -import math from abc import abstractmethod -from discord import ui, ButtonStyle, Interaction +from discord import ButtonStyle, Interaction, ui + from utils.embeds import Embed + class PageView(ui.View): def __init__(self, page_size: int): super().__init__(timeout=None) self.page_index = 0 self.page_size = page_size - + @property @abstractmethod def _title(self) -> str: pass - + @abstractmethod async def _load_content(self, from_idx: int, to_idx: int) -> tuple[int, str]: pass + def position_to_page_index(self, position: int) -> int: + return (position - 1) // self.page_size + async def load(self) -> Embed: + if self.page_index < 0: + self.page_index = 0 + num_items, content = await self._load_content( (self.page_index * self.page_size), - ((self.page_index + 1) * self.page_size - 1) + ((self.page_index + 1) * self.page_size - 1), ) - + embed = Embed(title=self._title) if num_items <= 0: embed.set_image(url="https://c.tenor.com/1rQLxWiCtiIAAAAd/tenor.gif") - self.clear_items() # remove buttons + self.clear_items() # remove buttons return embed - max_page_index = int(math.ceil(num_items / self.page_size)) - 1 + max_page_index = self.position_to_page_index(num_items) if self.page_index > max_page_index: # if the content changed and this is out of bounds, try again self.page_index = max_page_index return await self.load() embed.description = content - self.prev_page.disabled = (self.page_index <= 0) - self.next_page.disabled = (self.page_index >= max_page_index) + self.prev_page.disabled = self.page_index <= 0 + self.next_page.disabled = self.page_index >= max_page_index return embed @ui.button(emoji="⬅", label="Prev", style=ButtonStyle.gray) @@ -53,3 +60,23 @@ async def next_page(self, interaction: Interaction, _) -> None: self.page_index += 1 embed = await self.load() await interaction.response.edit_message(embed=embed, view=self) + + class JumpToModal(ui.Modal, title="Jump To Position"): + def __init__(self, view: "PageView"): + super().__init__() + self.view = view + self.position_field: ui.TextInput[PageView.JumpToModal] = ui.TextInput( + label="Position", placeholder="Enter position to jump to", required=True + ) + self.add_item(self.position_field) + + async def on_submit(self, interaction: Interaction) -> None: + position = int(self.position_field.value) + self.view.page_index = self.view.position_to_page_index(position) + embed = await self.view.load() + await interaction.response.edit_message(embed=embed, view=self.view) + + @ui.button(label="Jump", style=ButtonStyle.gray) + async def jump_to_position(self, interaction: Interaction, _) -> None: + modal = self.JumpToModal(self) + await interaction.response.send_modal(modal) diff --git a/rocketwatch/utils/visibility.py b/rocketwatch/utils/visibility.py index 0a5ffd84..a891bdc1 100644 --- a/rocketwatch/utils/visibility.py +++ b/rocketwatch/utils/visibility.py @@ -1,17 +1,16 @@ from discord import Interaction -from discord.ext.commands import Context from plugins.support_utils.support_utils import has_perms -def is_hidden(interaction: Context | Interaction): - return all(w not in interaction.channel.name for w in ["random", "rocket-watch"]) - - -def is_hidden_weak(interaction: Context | Interaction): - return all(w not in interaction.channel.name for w in ["random", "rocket-watch", "trading"]) +def is_hidden(interaction: Interaction): + channel_name = getattr(interaction.channel, "name", None) or "" + for allowed_channel in ["random", "rocket-watch", "trading"]: + if allowed_channel in channel_name: + return False + return False def is_hidden_role_controlled(interaction: Interaction): # reuses the has_perms function from support_utils, but overrides it when is_hidden would return false - return not has_perms(interaction, "") if is_hidden(interaction) else False + return not has_perms(interaction) if is_hidden(interaction) else False diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..83fe39d9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,30 @@ +import sys +from pathlib import Path +from types import ModuleType +from unittest.mock import MagicMock + +import discord + +# Add rocketwatch source to path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "rocketwatch")) + +# Stub out shared_w3 which connects to RPC endpoints at import time. +_shared_w3_stub = ModuleType("utils.shared_w3") +_shared_w3_stub.w3 = MagicMock() +_shared_w3_stub.w3_mainnet = MagicMock() +_shared_w3_stub.w3_archive = MagicMock() +_shared_w3_stub.bacon = MagicMock() +sys.modules["utils.shared_w3"] = _shared_w3_stub + +# Stub out utils.embeds which triggers CachedEns/web3 initialization at import time. +# Provide a minimal Embed class (discord.Embed subclass) for code that needs it. +_embeds_stub = ModuleType("utils.embeds") +_embeds_stub.Embed = discord.Embed +_embeds_stub.resolve_ens = MagicMock() +_embeds_stub.el_explorer_url = MagicMock() +_embeds_stub.prepare_args = MagicMock() +_embeds_stub.assemble = MagicMock() +sys.modules["utils.embeds"] = _embeds_stub + +# With the lazy proxy in utils.config, cfg is importable without loading a file. +# No stubbing needed — tests that need a real Config can set cfg._instance directly. diff --git a/tests/message_samples.json b/tests/message_samples.json new file mode 100644 index 00000000..de481870 --- /dev/null +++ b/tests/message_samples.json @@ -0,0 +1,840 @@ +{ + "messages": { + "safe": [ + { + "content": "hey <@186737813943746560> i can't seem to recall...does your mollyguard script provide reboot rescheduling during pruning?" + }, + { + "content": "https://twitter.com/northrocklp/status/1565681596585295873?s=21&t=-v_C_k4RFgwtwQEfBtgQBw", + "embeds": [ + { + "title": null, + "description": "LFG" + } + ] + }, + { + "content": "<@368446312800190464> I set your AFK: AFK" + }, + { + "content": "but its not working ... `rocketpool_validator | DBG 2023-01-23 18:52:45.879+00:00 Could not obtain genesis information from beacon node node=lighthouse.rescuenode.com:80 node_index=0 node_roles=AGBSD error_name=RestCommunicationError error_msg=\"Communication failed while sending/receiving request, http error [HttpReadError]: Could not read response headers\"`" + }, + { + "content": "I'm sorry <@215234895092383744>, I can't do that for you. Please summarize what you intend to do with the test eth, and a yellow or orange or Viennese user will help you." + }, + { + "content": "Do the investment DAOs get fully subscribed pretty quickly? Can't tell if this is late stage top signal or early shiny new bull run idea hah" + }, + { + "content": "<@576556756616871947>", + "embeds": [ + { + "title": "ETH APR for LEB8 vs 16 ETH minipool", + "description": "`ETH_apr = solo_stake_apr * (NO_ETH + Protocol_ETH*commission)/(NO_ETH + NO_RPL_value_in_ETH)`\n- Minipool16s at 15% commission get 104.55% of solo stake apr\n- Minipool16s at 20% commission get 109.10% of solo stake apr\n- LEB8s at 14% commission get 109.23% of solo stake apr\n\nThis is strictly ETH rewards divided by total investment, assuming minimum RPL investment (1.6 ETH worth for minipool16, 2.4 ETH worth for LEB8).\n\nRPL yield and RPL appreciation/depreciation not accounted for. If you're bullish or even neutral on RPL, this is a clear win.\n\nIf you're thinking about this from a migration standpoint and already hold RPL, note that it'll look even better. In the extreme where you \"want to hold enough RPL anyhow\", you can remove the RPL term entirely in the denominator (as the RPL investment in that case isn't for the ETH commission). For our three scenarios, the numbers in that case are 115%, 120%, and 142% respectively. \n\n*Last Edited by <@109422960682496000> *" + } + ] + }, + { + "content": "https://docs.rocketpool.net/guides/node/create-validator#whitelisting-an-address-to-stake-on-behalf", + "embeds": [ + { + "title": "Rocket Pool Guides & Documentation", + "description": "Rocket Pool Guides & Documentation - Decentralised Ethereum Liquid Staking Protocol" + } + ] + }, + { + "content": "https://www.validatorqueue.com/", + "embeds": [ + { + "title": "Validator Queue", + "description": "A dashboard showing the Ethereum validator enter and exit queue and estimated wait times." + } + ] + }, + { + "content": "Yeah shit like this has been brutal\n\nhttps://www.rescue.org/press-release/irc-and-map-urgent-call-international-action-some-gaza-survive-little-3-minimum-daily", + "embeds": [ + { + "title": "IRC and MAP: Urgent call for international action as some in Gaza s...", + "description": "Amidst Israel’s military invasion of Rafah that threatens further deterioration to the Water, Sanitation and Hygiene (WASH) conditions in southern Gaza, and based on recent trips to Gaza, the International Rescue Committee (IRC) and Medical Aid for Palestinians (MAP) are alarmed that:" + } + ] + }, + { + "content": "https://blockworks.co/news/binance-us-coinbase-curve-in-bidding-war-for-blockfi-credit-card-customers/", + "embeds": [ + { + "title": "Binance US, Coinbase, Curve in Bidding War for BlockFi Credit Card ...", + "description": "A bidding war has reportedly erupted between two centralized exchanges and a fintech player, all seeking to acquire BlockFi's card assets." + } + ] + }, + { + "content": "Yeah I got bored of your drone quickly tbh" + }, + { + "content": "It keeps trading after level unlocks" + }, + { + "content": "its just doing something different" + }, + { + "content": "I could have a physical backup node but i am scared of the goverment scanning the network and install a trojaner on my node and steal funds (idk if possible) or get the node slashed" + }, + { + "content": "oh shit we have to give the money upfront?" + }, + { + "content": "You mean you checked the payload?" + }, + { + "content": "finally catch up to ADA" + }, + { + "content": "a much stronger case can be made if rocketpool team works on behalf of all node operators, rather than individual node operators.\nThe node operators here are probably the most eth-aligned group of people. Without us, there is no rETH, and no restaking with rETH" + }, + { + "content": "https://docs.rocketpool.net/guides/node/cli-intro.html#exit" + }, + { + "content": "Beats the heck out of what I was making as an engineer" + }, + { + "content": "i'll be putting some $$ into this ICO this friday - 2 people i know irl are behind the project https://metadao.fi/projects/solomon/fundraise" + }, + { + "content": "according to this https://rocket-pool.readthedocs.io/en/latest/smart-node/node-setup.html im at the point where i need to request RPL and I hear there are none in the faucet, then after that I would register my node, which might fill in the gaps in the config. I am not sure." + }, + { + "content": "i unbanned you <@851524243861536819>" + }, + { + "content": "available to everyone.\ncheck out the rocketpool section here: https://kb.beaconcha.in/beaconcha.in-explorer/mobile-app-less-than-greater-than-beacon-node", + "embeds": [ + { + "title": "Mobile App <> Node Monitoring", + "description": "A step by step tutorial on how to monitor your staking device & beaconnode on the beaconcha.in mobile app." + } + ] + }, + { + "content": "someone mentioned that there were slashings today?" + }, + { + "content": "<@178971072169902087> have 69eth?" + }, + { + "content": "if they know what they should be looking for .. yes." + }, + { + "content": "_Primary_\n**[1 rETH = 1.055207 ETH](https://stake.rocketpool.net)**\n**[1 wstETH = 1.105694 ETH](https://stake.lido.fi/wrap)**\n**[1 cbETH = 1.022743 ETH](https://www.coinbase.com/cbeth/whitepaper)**\n_Secondary ([1Inch](https://app.1inch.io/#/r/0xB0De8cB8Dcc8c5382c4b7F3E978b491140B2bC55))_\n**[1 rETH = 1.067503 ETH](https://app.1inch.io/#/1/classic/limit-order/0xae78736Cd615f374D3085123A210448E74Fc6393/WETH)** (1.165% premium)\n**[1 wstETH = 1.103085 ETH](https://app.1inch.io/#/1/classic/limit-order/WETH/0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0)** (0.235% discount)\n**[1 cbETH = 0.998861 ETH](https://app.1inch.io/#/1/classic/limit-order/WETH/0xbe9895146f7af43049ca1c1ae358b0541ea49704)** (2.335% discount)\n_[bot](https://github.com/xrchz/discord) by ramana.eth (0x65FE…092c)_" + }, + { + "content": "<@!359845746058592266> did a bunch of tokenomics explaining" + }, + { + "content": "more sellers than buyers <@120342047109545984>" + }, + { + "content": "<@708771937983397898> so how are the skimming rewards handled ... I also run a solo stake and I get the rewards every 4 days or so, those rewards go to the fee distributor for the minipool now?" + }, + { + "content": "https://app.uniswap.org/#/swap?inputCurrency=0xb4efd85c19999d84251304bda99e90b92300bd93" + }, + { + "content": "Looks like we have some lobbying to do......\nhttps://ethereumorgwebsitedev01-stakingepic.gtsb.io/en/staking/pools/" + }, + { + "content": "PSA for RP Heroglyph miner `0xff6C422c6e9A53200798A771f25b72B96d4eCa64`\nYou have to use this method to adjust your graffiti . You, unfortunately, had a malformed tag. `#69,BERA@xxx-NN v1.13.0` <:NotLikeThis:814602648224923700>" + }, + { + "content": "Well, anyways, after resolving both my own and <@902166175641907210>'s ETH1 peercount problem, I'm almost certain the reason (after taking care of our port forwarding, etc) was our clock being out of sync. The geth docs talk about this, that's where I found the solution. So to help any poor future soul running into the same problem... Here comes:" + }, + { + "content": "https://twitter.com/search?q=%24rETH and this", + "embeds": [ + { + "title": "$rETH - Twitter Search", + "description": "The latest Tweets on $rETH. Read what people are saying and join the conversation." + } + ] + }, + { + "content": "https://imgflip.com/i/8fne4k", + "embeds": [ + { + "title": "Being/working as xxxxxx is not stressful at all", + "description": null + } + ] + }, + { + "content": "Hey <@&1138708013239246948>, **Rocket\\_Pool** just posted a new Tweet!\n", + "embeds": [ + { + "title": null, + "description": "https://t.co/cqGhAkGNU6" + } + ] + }, + { + "content": "Hey <@&1138708013239246948>, **Rocket\\_Pool** just posted a new Tweet!\n", + "embeds": [ + { + "title": null, + "description": "Keep up with developments in the Rocket Pool ecosystem - the first biweekly protocol update for 2025 is available now on Medium:\nhttps://t.co/4oerCknh3b" + } + ] + }, + { + "content": "Hey <@&1138708013239246948>, **Rocket\\_Pool** just posted a new Tweet!\n", + "embeds": [ + { + "title": null, + "description": "https://t.co/g5ATgzTM9q" + } + ] + }, + { + "content": "<@209114180186275840> I am very curious on Nodeset's take once you finish your analysis. My guess is that allowing to lend RPL in combination with the pressure to have it productive by incentivizing low collateral minipools would be a game changer for RP growth" + }, + { + "content": "1) https://dao.rocketpool.net/t/reth-incubator-submission-feedback-complete/3397/3?u=shfryn\n2) Incubator Awards Announced\n3) Forum Post", + "embeds": [ + { + "title": "rETH Incubator Submission Feedback Complete", + "description": "Eligibility The GMC reviewed knoshua’s eligibility in light of the original terms, which stated that reviewers would not qualify for submission rewards. Despite knoshua’s own scores being excluded from calculations, their submission secured second place by a significant margin. Knoshua had been asked to step into the reviewer role after an exte..." + } + ] + }, + { + "content": "<@186737813943746560> why do you think we need the ability to transfer minipools to another node?" + }, + { + "content": "This is much more stable link \n\nhttps://twitter.com/i/broadcasts/1yNxaYYpBdExj" + }, + { + "content": "https://twitter.com/salvinoarmati/status/1624131884036018176?s=12&t=UNu9tt90I8AxJz7kjkY7mg", + "embeds": [ + { + "title": null, + "description": "in light of gary gensler's recent consumer \"protection\"\n\nrequest for product: A turn-key at-home ETH staking node.\n\nsetup should be 3 steps:\n\n1. plug in device\n2. connect to internet\n3. send ETH to generated address" + } + ] + }, + { + "content": "https://twitter.com/circle/status/1634341007306248199?s=46&t=Slr6-WAzklLyTF3kj73rsQ", + "embeds": [ + { + "title": null, + "description": "Silicon Valley Bank is one of six banking partners Circle uses for managing the ~25% portion of USDC reserves held in cash. While we await clarity on how the FDIC receivership of SVB will impact its depositors, Circle & USDC continue to operate normally.\nhttps://t.co/NU82jnajjY" + } + ] + }, + { + "content": "https://tenor.com/view/cottoncandy-racoon-funny-animal-gif-5846999" + }, + { + "content": "merge launch party call POAP is available until 00:00 2022-09-18" + }, + { + "content": "https://rocketpool.steely-test.org/ so I mean the validators with under 80% performance over the last 1 day period. the non zero gone I mean excluding the validators with zero performance over the 1 day as they were likely already down.", + "embeds": [ + { + "title": "Rocket Pool Performance Report - 7 Days (80%)", + "description": "Dashboard for Rocket Pool nodes showing underperforming operators across different time periods and thresholds. If you need help pop into #support on discord and we can help you get back online!" + } + ] + }, + { + "content": "There’s a fair amount of historical context around ideas like this. The closest example that actually passed is probably this bounty: https://rpbountyboard.com/BA062302\n\nLongForWisdom spent a significant amount of time expanding on it, but I think it was difficult to get broad support because many members felt it was too complex to properly scope in advance. While it did pass, it ultimately saw limited usage. It’s unclear whether that was primarily due to a subsequent drop in activity, or whether the predefined bounty structure itself introduced additional friction.\n\nSomething that could be particularly valuable would be a draft bounty written by someone with hands-on experience in this area (e.g., sckuzzle, Dr Doofus, halo, etc.), outlining realistic scenarios based on projects they’ve personally worked on.\n\nMy personal view is that, unless a proposal received overwhelming support and clearly accounted for the full range of potential scenarios, I would instead prefer these to be submitted on a case-by-case basis, based on how similar efforts have played out in the past. I’m generally supportive of funding more work, but I’m concerned that if the bounty or process isn’t well-structured or doesn’t gain traction, it could unintentionally lead to the opposite outcome." + }, + { + "content": "Commented on a Vitalik post: https://www.reddit.com/r/ethereum/s/FSqj0AL1uH\n\nAnd added a reaction to the community call: https://youtube.com/watch?v=ygvpjXypGW0&lc=UgzbSldP-dh5G7nwH_B4AaABAg&si=0FmP5SmzfvihDotp\n\nAnd I upvoted the few other Reddit comments about Rocket Pool in the daily r/ethereum 😁", + "embeds": [ + { + "title": "Kevkillerke's comment on \"Welcome to 2026!\"", + "description": "Explore this conversation and more from the ethereum community" + }, + { + "title": "Rocket Pool Community Call | 15 January 2026", + "description": "Ken chats with Langers about the latest protocol news, including the Saturn Upgrade status.\n\nWEBSITE: https://rocketpool.net\nX (TWITTER): https://twitter.com/rocket_pool\nDISCORD: https://discord.gg/rocketpool\n\nRocket Pool is Ethereum’s most decentralised liquid staking protocol. Its 1,000+ worldwide node operators have staked over half a milli..." + } + ] + }, + { + "content": "may be this https://rocketpool.steely-test.org/", + "embeds": [ + { + "title": "Rocket Pool Performance Report - 7 Days (80%)", + "description": "Stop sucking with your Rocketpool performance please. I can not tell you where to get help because Haloooloolo says I can't but just FIX IT!" + } + ] + }, + { + "content": "https://fixvx.com/AaronRDay/status/2001070751768830025", + "embeds": [ + { + "title": null, + "description": "For You \"Elon Saved Free Speech\" White Knight Assholes\\: Read the New Terms of Service Released Today\n\nX just updated their Terms of Service effective January 15, 2026\\. Here's what you agreed to\\:\n\nAI TRAINING RIGHTS GRAB\\: Everything you post becomes training data for their AI models\\. Every thought, opinion, creative work\\. You're building their models for free\\. No compensation\\. No opt out\\.\n\nPERPETUAL CONTENT LICENSE\\: They get a worldwide, royalty\\-free license to use, copy, modify, and distribute your content \"for any purpose\" in \"any media now known or later developed\\.\" Forever\\. They can sell it\\. Give it to governments\\. Anything\\.\n\nFORCED JURISDICTION\\: All disputes must be filed in Tarrant County, Texas\\. You waive the right to join class actions\\. If they wrong millions of users, you sue alone in THEIR court\\.\n\nARBITRARY TERMINATION\\: They can delete your account \"for any other reason or no reason at our convenience\\.\" Years of content, connections, reputation\\. Gone\\. Z…" + } + ] + }, + { + "content": "after listening to https://youtu.be/8FzR7Ae1Kwo?si=yXGv_V5S3ZXFFYa4&t=1428, it sounds like if i exited my minipools before luanch, i wont get express tickets. did that person in the call mean to say 'if you close your minipools before launch', not exit?", + "embeds": [ + { + "title": "Rocket Pool Community Call | 10 February 2026", + "description": "Ken chats with Langers about the latest protocol news, including the Saturn Upgrade status.\n\nWEBSITE: https://rocketpool.net\nX (TWITTER): https://twitter.com/rocket_pool\nDISCORD: https://discord.gg/rocketpool\n\nRocket Pool is Ethereum’s most decentralised liquid staking protocol. Its 1,000+ worldwide node operators have staked over half a milli..." + } + ] + }, + { + "content": "mamdani is the only dem who has figured out how to play trump https://fxtwitter.com/seungminkim/status/2027136964256752050?s=20", + "embeds": [ + { + "title": null, + "description": "Inside this latest Trump\\-Mamdani meeting\\:\n︀︀\\-Last time the two met, Trump asked him to return with ideas to build big things\\. Mamdani came back with a massive housing proposal\n︀︀\\-Mamdani's team created mock headlines to show Trump how such a project would be received\\. He was \"very enthusiastic\\.\"\n︀︀\\-Mamdani pushed for release of Columbia student detained today, Trump calls him later to tell him she's being released\n︀︀\\-Mamdani gives Susie Wiles a list of four other students he wants help with, all targeted in pro\\-Palestinian protests [apnews.com/article/donald-trump-zohran-mamdani-new-york-housing-3835daca395dbe46c2f3da2433ec24f4](https://apnews.com/article/donald-trump-zohran-mamdani-new-york-housing-3835daca395dbe46c2f3da2433ec24f4)\n\n> **[Quoting](https://x.com/NYCMayor/status/2027113267710021738) Mayor Zohran Kwame Mamdani \\([@NYCMayor](https://x.com/NYCMayor)\\)**\n> ︀\n> I had a productive meeting with President Trump this afternoon\\.\n> ︀︀\n> ︀︀I’m looking forward to building more housing in New York City\\.\n\n**[💬](https://x.com/intent/tweet?in_reply_to=2027136964256752050) 393 [🔁](https://x.com/intent/retweet?tweet_id=2027136964256752050) 3\\.7K [❤️](https://x.com/intent/like?tweet_id=2027136964256752050) 59\\.3K 👁️ 4\\.98M **" + } + ] + }, + { + "content": "https://x.com/primestakepool/status/2013238902086054197", + "embeds": [ + { + "title": null, + "description": "Rocketpool Saturn 1 new launch target is 9th Feb 2026\\. We are eagerly waiting to know more abt rpl's \\- stake on behalf of a node, service to launch our \\#Ethereum \\#staking service\\. For more details see the blog link\\.\n\\#cryptocurrencies \\#rocketpool \\#web3 \nhttps://t.co/SE4d2HIWKK" + } + ] + }, + { + "content": "If I want to convert my minipool to a megapool with two 4 ETH validators, these are roughly the steps?\n\n1) `r m exit`\n2) `r m close`\n3) `r n withdraw-rpl`\n\nAt this point all `eth` both from bond and rewards + all `rpl` are in my withdrawal wallet, right? \n\nThen I just proceed regularly with the creation of the megapool validator (https://docs.rocketpool.net/node-staking/megapools/create-megapool-validator)\n\nIs that the process? \n\na) Is there a way to check before starting this process that my smartnode was given the express tickets? \nb) At which point are the express tickets issued?" + }, + { + "content": "https://www.youtube.com/watch?v=OC7sNfNuTNU", + "embeds": [ + { + "title": "400 car batteries wired together!!", + "description": "If you have ideas for ridiculous science experiments that you’d like me or another youtuber to try, submit them at http://anydesk.com/science . The ideas that are brought to reality will win cool prizes and lead to the creation of epic videos.\n\nlinks:\ndiscord: https://discord.gg/styropyro\nsecond channel: https://www.youtube.com/@styropyroshort..." + } + ] + }, + { + "content": "https://www.youtube.com/watch?v=FRZ9cUEF0NE&list=RDFRZ9cUEF0NE&start_radio=1", + "embeds": [ + { + "title": "Pokemon Diamond/Pearl: Approaching Champion Cynthia Piano Etude (Ex...", + "description": "Here's the infamously hard/iconic battle introduction music for Cynthia, the Sinnoh Champion. This track is very tricky, especially if you don't have wide hands. Players be warned!\n\nSheets (discontinued): https://www.musicnotes.com/l/lckFP\n\nPiano Man's Discord (Ages 13+): https://discord.gg/Qj6Zp2S\nSpotify: https://open.spotify.com/artist/4LtoFc..." + } + ] + }, + { + "content": "Well so much for trying to post my screenshots with my message, lets try this again:\nHave been having some issues since Fusaka, maybe similar to rabidsloth? I believe I had upgraded in time but after the fork corrupted the database while doing a system update and not shutting down rockepool first. I was seeing bad blocks and chain wasn't syncing and using my fallback instead.\nWhat I am running \nRocket Pool client version: 1.18.6\nRocket Pool service version: 1.18.6\nSelected Eth 1.0 client: Geth (Locally managed)\n Image: ethereum/client-go:v1.16.7\nSelected Eth 2.0 client: Lighthouse (Locally managed)\n Image: sigp/lighthouse:v8.0.1\nI have since over the course of the last week or so:\n - Upgraded to Lighthouse v8.0.1 from v8.0.0\n - Resynced Geth\n - Updated checkpoint sync to https://mainnet.checkpoint.sigp.io/\n - Resynced Lighthouse\n - Upped my peer counts (33 Geth, 70 LH)\n - Restarted Rocketpool service anytime I saw the \"check execution node for corruption then restart it and Lighthouse\"", + "embeds": [ + { + "title": "Checkpointz", + "description": "An Ethereum beacon chain checkpoint sync provider" + } + ] + }, + { + "content": "I need some assistance with minipool exit. The hardware of one of my home nodes got fried a few weeks ago and I decided to exit the minipools that the node was running. \nThe exit was initiated almost a month ago and the estimate was that it will complete around Dec 28. \nNot sure what the status is because I still get an error on `rocketpool minipool close`\n`NOTE: The following minipools have not had their full balances withdrawn from the Beacon Chain yet:`\n\nValidator:\n0xb2ab9fa69b83198f6919963cc8a2b0d3512c5ce566f0918915ef6e8db71a15f1b33dff5cb6e4122cc79c33a67128d353\nhttps://beaconscan.com/validator/347240" + }, + { + "content": "https://x.com/sentdefender/status/2001469158836638056?s=46", + "embeds": [ + { + "title": null, + "description": "The Trump Administration has begun asking American\\-based oil companies, including but not limited to Exxon, ConocoPhillips, Halliburton and Weatherford, if they would be interested in returning operations to Venezuela once President Nicolás Maduro has been removed from power, and" + } + ] + }, + { + "content": "<@326039902057791499> <@360474629988548608> \n\nDiscussed with the team & we have decided not to implement this. \n\nThe primary reason is allowing other tokens other than ETH to be exchanged, brings us a lot closer to the line of creating a financial market under Australian Law, which would have significant regulatory implications.\n\nEven though we are using CowSwap and not market making, implementing this request would bring us dangerously close to what ASIC considers \"dealing\" and we don't think it's worth the risk. \n\nPeople can use CowSwap directly for these types of transactions.\n\nHere is the ASIC guide: (it's chunky)\n\nhttps://www.asic.gov.au/regulatory-resources/digital-transformation/digital-assets-financial-products-and-services/\n\nLet me know your thoughts or if you have any further questions." + }, + { + "content": "L\nM\nA\nO\n\nUSA most corrupt country in the world for sure sure sure rn \n\nhttps://xcancel.com/lisadnews/status/2008998959235407904?", + "embeds": [ + { + "title": "Lisa Desjardins (@LisaDNews)", + "description": "BREAKING: The Trump administration plans to put money raised from seizure of Venezuelan oil into bank accounts *outside* the U.S. Treasury -- they told lawmakers today per multiple sources familiar.\n\nSources said they understood these as similar or decidedly \"off-shore\" accounts. \n\nAsking the WH for clarification." + } + ] + }, + { + "content": "https://x.com/ProDJKC/status/2014358713742553478\n\nSPECIAL EDITION!\n\nThis week’s Doots Podcast is a special edition with Erica Khalili, co-founder and Chief Legal & Risk Officer at Lead Bank.\nErica’s been building the legal and compliance plumbing behind modern fintech and crypto banking, including work at Square/Block. She's joining us to talk through what’s actually changing in banking right now.", + "embeds": [ + { + "title": null, + "description": "📣2pm ET Doots Podcast BONUS\\! Erica Khalili, co\\-founder and Chief Legal & Risk Officer @Lead\\_Bank Erica’s been building the legal/compliance behind crypto banking, including work at @Square\\. Join Us to talk through what’s actually changing in banking right now\\. @Lead\\_Bank" + } + ] + }, + { + "content": "https://fixvx.com/yarotrof/status/2029694600890532315?s=46&t=yFjBTj1xudWk17NTzRAvDQ", + "embeds": [ + { + "title": null, + "description": "Hungary seized a Ukrainian bank convoy transporting $80 million in cash and gold from Austria…\n\n> **QRT\\: [andrii_sybiha](https://twitter.com/i/status/2029687554568593623)**\n> Today in Budapest, Hungarian authorities took seven Ukrainian citizens hostage\\. The reasons are still unknown, as well as their current well\\-being, or the possibility of contacting them\\.\n> \n> These seven Ukrainians are employees of state\\-owned Oschadbank, who were operating two bank cars transiting between Austria and Ukraine and carrying cash as part of regular services between state banks\\.\n> \n> In fact, we are talking about Hungary taking hostages and stealing money\\. If this is the “force” announced earlier today by Mr Orban, then this is a force of a criminal gang\\. This is state terrorism and racketeering\\.\n> \n> We have already sent an official note demanding an immediate release of our citizens\\.\n> \n> We will also address the European Union with the request to provide a clear qualification of Hungary’s unlawful actions, hostage\\-taking, and robbery\\.\n> \n> Statement by Osc…" + } + ] + }, + { + "content": "https://fixvx.com/i/status/2026806598039920958", + "embeds": [ + { + "title": null, + "description": "incredible exchange\n\n> **QRT\\: [DropSiteNews](https://twitter.com/i/status/2026748211914813850)**\n> Sen\\. Fetterman says he’ll vote “no” on the Paul\\-Kaine Iran War Powers Resolution in the Senate\\.\n> \n> When Drop Site’s Julian Andreone pressed him on how strikes on Iran would benefit Americans in Pennsylvania, he replied\\: “Oh, it absolutely does\\. It makes the Middle East safer\\.”\n> \n> Asked again how it helps Pennsylvanians, he answered\\: “Absolutely\\.”\n> \n> @JulianAndreone \\| @JohnFetterman" + } + ] + }, + { + "content": "https://youtu.be/T4Upf_B9RLQ", + "embeds": [ + { + "title": "A Day in the Life of an Ensh*ttificator", + "description": "Digital products and services keep getting worse. In the new report Breaking Free: Pathways to a fair technological future, the Norwegian Consumer Council has delved into enshittification and how to resist it. The report shows how this phenomenon affects both consumers and society at large, but that it is possible to turn the tide. \n\nRead more o..." + } + ] + }, + { + "content": "https://fxtwitter.com/kartojal/status/1999741511919948100", + "embeds": [ + { + "title": null, + "description": "IMO [@aave](https://x.com/aave) frontend ownership debate arrives 2 years late, this should have been mention before but Aave DAO paid for this development without “owning” the ending product\\.\n︀︀\n︀︀Yes, Aave Labs paid salaries to develop the Aave Interface, but first payment from the DAO to cover interface costs was in 2022\\.\n︀︀\n︀︀Retrofunding was used to pay for Aave V3 interface development and interface development is always mentioned in Aave Labs development updates\\.\n︀︀\n︀︀The aave\\-interface website License was changed in 2023 from BSL to closed license without tokenholder consensus\\:\n︀︀\n︀︀[github.com/aave/interface/commit/47430b891e1a6ee6a5e98a1328a0ca482b13717f](https://github.com/aave/interface/commit/47430b891e1a6ee6a5e98a1328a0ca482b13717f)\n︀︀\n︀︀Mention of interface updates recently \\(so they are part of Aave Labs service provider budget, if not, why mention?\\)\\:\n︀︀\n︀︀[governance.aave.com/t/al-development-update-july-2025/22779](https://governance.aave.com/t/al-development-update-july-2025/22779)\n︀︀\n︀︀Mention of “Front\\-end Engineering” costs in retrofunding\\:\n︀︀\n︀︀[governance.aa](https://governance.aave.com/t/arc-aave-v3-retroactive-funding/9250)…" + } + ] + }, + { + "content": "super-ultra-rage: The feeling I get when I forget to `@everyone` in a pingserver ping and have to add a new post just to do that." + }, + { + "content": "Finally some drama https://fxtwitter.com/Marczeller/status/1999408520316453321", + "embeds": [ + { + "title": null, + "description": "Extremely concerning\\.\n︀︀\n︀︀The stealth privatization of approximately 10% of Aave DAO's potential revenue, leveraging brand and IPs paid for by the DAO, represents a clear attack on the best interests of the $AAVE Token holders\\.\n︀︀\n︀︀We will prepare an official response with [@AaveChan.](https://x.com/AaveChan.)\n\n> **[Quoting](https://x.com/fredcat5150/status/1999124321881543157) fredcat \\([@fredcat5150](https://x.com/fredcat5150)\\)**\n> ︀\n> Did Aave Labs quietly redirect millions in swap fees away from the DAO treasury?\n> ︀︀\n> ︀︀[governance.aave.com/t/aave-cowswap-integration-tokenholder-questions/23530](https://governance.aave.com/t/aave-cowswap-integration-tokenholder-questions/23530)\n> ︀︀\n> ︀︀$Aave delegate [@DeFi_EzR3aL](https://x.com/DeFi_EzR3aL) just posted some on\\-chain research\\. The following thread breaks down his post\n> ︀︀🧵\n> ︀︀\n> ︀︀[@Marczeller](https://x.com/Marczeller) [@StaniKulechov](https://x.com/StaniKulechov) [@DeFi_EzR3aL](https://x.com/DeFi_EzR3aL)\n\n**[💬](https://x.com/intent/tweet?in_reply_to=1999408520316453321) 40 [🔁](https://x.com/intent/retweet?tweet_id=1999408520316453321) 31 [❤️](https://x.com/intent/like?tweet_id=1999408520316453321) 365 👁️ 59\\.4K **" + } + ] + }, + { + "content": "https://fxbsky.app/profile/ryanestrada.com/post/3mdbvk4ycdc2l", + "embeds": [ + { + "title": null, + "description": "This was just me making this comparison yesterday, but today the Korean news is calling Minnesota \"America's Gwangju\" and have human rights lawyers explaining how America is now living through the dictatorship Korea once had\\.\n\n> **[Quoting](https://bsky.app/profile/ryanestrada.com/post/3md7qa72hos2c) Ryan Estrada \\([@ryanestrada.com](https://bsky.app/profile/ryanestrada.com)\\)**\n> ︀\n> The Chun regime that my wife fought against in the 80s did the exact same thing\\. They needed a \"crisis\" that \"only they could solve\" to hoard more ill\\-gotten power, so they chose one city \\(Gwangju\\), declared its inhabitants enemies, sent trucks full of soldiers to violently attack and kill them\\.\n\n**🔁 808 ❤️ 2\\.2K **" + } + ] + }, + { + "content": "https://fxtwitter.com/heretowanderman/status/2029701875830857831?s=20", + "embeds": [ + { + "title": null, + "description": "Why is Shane Curran following Micky and both Ribbita X’s?\n︀︀\n︀︀So weird\\. Must be the wind\\. Why would a Series B funded startup follow a meme coin\\.\n︀︀\n︀︀Honestly, why do all these high profile startups and Rebels follow $TIBBIR?\n︀︀\n︀︀Just a meme? Or foundational Tech?\n\n> **[Quoting](https://x.com/arcurn/status/2029542271255732519) Shane Curran \\([@arcurn](https://x.com/arcurn)\\)**\n> ︀\n> Today, we’re excited to announce [@Evervault](https://x.com/Evervault)'s $25M Series B, led by Ribbit Capital with continued support from [@sequoia](https://x.com/sequoia), [@IndexVentures](https://x.com/IndexVentures), [@kleinerperkins](https://x.com/kleinerperkins), and [@nextplayVC.](https://x.com/nextplayVC.)\n> ︀︀\n> ︀︀This round comes at a time when sensitive data exchange on the web is going parabolic\\. Since 2019, we’ve been focused on building durable infrastructure for engineering teams to collect, process, share, and enrich sensitive data \\-\\- while keeping it encrypted at all times\\.\n> ︀︀\n> ︀︀We thought we were making good progress in encrypting the web, helping customers like [@tryramp](https://x.com/tryramp), [@Rippling](https://x.com/Rippling), [@finix](https://x.com/finix), [@TheOverwolf](https://x.com/TheOverwolf), [@Uniswap](https://x.com/Uniswap), [@CarTrawler](https://x.com/CarTrawler), and hundreds of others secure more…" + } + ] + }, + { + "content": "Hello @here and <@&918359147710410782>s!\n\nThe Rocket Pool team is happy to release **v1.19.0-rc1** of the Smart Node! The Rocket Pool community is welcome to join us on the Hoodi Testnet to test all the Saturn-1 upgrade features or try to break them before the upgrade goes live on Mainnet!\n\n## Client Updates\n\n- Lodestar updated to v1.39.1\n- MEV-Boost updated to v1.11.0;\n\n## Smart Node changes\n- Rewards V11 was implemented and scheduled for the next interval (140);\n- Megapool commands are visible and don't require a flag;\n- RPL previously staked on the node is now considered `Legacy RPL`. There is no migration from legacy RPL. Users will need to withdraw from legacy and stake on the megapool if desired.\n- You can stake RPL with `rocketpool node stake-rpl`;\n- To unstake RPL (both from Legacy RPL or from the megapool) there is a unstaking period (48 hours on the Testnet).\n- The command `rocketpool node withdraw-rpl` is used to request the withdraw and also to complete it after the unstaking period;\n- RPL staked on the megapool will be considered for voter share rewards (see RPIP-46).\n- The Smart Node will automatically set the correct fee recipient, according to these rules:\n - If the node has joined the smoothing pool -> smoothing pool address\n - If the node is not part of the smoothing pool AND:\n - only has minipools -> node distributor contract address\n - only has megapool validators -> megapool contract address\n - has both minipools and megapool validators -> the fee recipient will be defined per validator using the keymanager API \n- If you wish to change the Keymanager API port, you may do so using `rocketpool service config` under the CC (ETH2) menu. \n\n## Megapool menu (`rocketpool megapool, g`)\n\n- **deploy** — Deploy a megapool contract for your node. This can be done automatically on the first deposit\n- **deposit** (`d`) — Make a deposit and create new validator(s). Use `--count N` for up to 35 deposits on the same transaction and `--express-tickets` to define the amount of express tickets\n- **status** (`s`) — Show the node’s megapool status\n- **validators** (`v`) — List the megapool’s validators and their state\n- **repay-debt** (`r`) — Repay megapool debt\n- **reduce-bond** (`e`) — Reduce the megapool bond\n- **claim** (`c`) — Claim distributed megapool rewards that haven’t been claimed yet\n- **stake** (`k`) — Stake a megapool validator. There is a node task that tries to stake automatically\n- **exit-queue** (`x`) — Exit a validator from the megapool queue\n- **exit-validator** (`t`) — Request to exit a megapool validator from the beacon chain\n- **notify-validator-exit** (`n`) — Notify that a validator exit is in progress. There is a node task that tries to notify the exit automatically. A beacon proof is required.\n- **notify-final-balance** (`f`) — Notify that a validator exit completed and the final balance was withdrawn. There is a node task that tries to notify the final balance withdrawal automatically. A beacon proof is required. In case this proof is not provided for some time, a more complex historical beacon proof will be needed (this may require access to an archive node). In case users don't have access to an archive node, the Smart Node will automatically request the historical proof from an API provided by the Rocket Pool team.\n- **distribute** (`b`) — Distribute accrued execution layer rewards sent to this megapool\n- **set-use-latest-delegate** (`l`) — Enable or disable using the latest delegate contract (`true` / `false`).\n- **delegate-upgrade** (`u`) — Upgrade the megapool’s delegate contract to the latest version\n- **dissolve-validator** (`i`) - Dissolve a validator with invalid credentials or a prestaking validator that failed to stake in time\n\nAs this is a pre-release version, the download command to be used is: \n`wget -O ~/bin/rocketpool\n\nThanks everyone!\nRocket Pool <:rocketpool:1406836483913941074>" + }, + { + "content": "Hello @here and @Node!\n\nWe're releasing `v1.19.1` of the Smart Node. It contains a bug fix and many quality of life updates.\n\nThis is a recommend upgrade for all users **and a required update for the <@&886163752553164830>**. This version implements the changes defined on RPIP-77, making minipools use the latest delegate automatically. \n\n**IF YOU DO NOT WISH TO OPT INTO USING THE LATEST DELEGATE CONTRACT ON YOUR MINIPOOLS, you should not install this version!**\n\n\n\n## Client Updates\n- Besu updated to v26.1.0;\n- Nimbus updated to v26.1.0;\n\n## Smart Node Updates:\n- Fix selecting rewards ruleset for the approximator;\n- Add a task to automatically submit txs for minipools to use the latest delegate. See [RPIP-77](https://rpips.rocketpool.net/RPIPs/RPIP-77) for more details;\n- Add option to send all tokens from the node wallet. If ETH is selected a tiny gas reserve will be kept.\n- Add option to exit multiple validators from the queue;\n- Improve the gas estimation for multi deposits so users can send more deposits getting closer to the tx gas limit;\n- Use `usableCredit` when calculating the remaining amount to be sent for partial credit deposits;\n- Add the `assign-deposits` command;\n- Show the queue position when selecting validators to exit;\n- Show the estimate queue position when depositing, so users can better choose when to use express tickets;\n\nTo install it, please follow our Smart Node upgrade guide here: \n\nThanks everyone!\n\nREMINDER: To opt into the Node Operator role to receive these announcements, react with 👍 to the post linked below:\n\n\nRocket Pool <:rocketpool:1406836483913941074>", + "embeds": [ + { + "title": "RPIP-77: Set Smart Node Default to Use Latest Delegate for Minipools", + "description": "Update Smart Node so by default minipools use the latest protocol-approved delegate and remove supported Smart Node configuration paths for setting older delegate implementations." + } + ] + }, + { + "content": "Hello @here and <@&918359147710410782>s!\n\nWe're releasing **v1.19.4** of the Smart Node. It's maintenance release reducing memory usage and data transfers between the node and the clients.\n \n**This is a required upgrade for <@&886163752553164830> nodes before the next rewards interval.**\nThis is a high-priority upgrade for Teku and Lighthouse users who didn't manually update and a recommended upgrade for all the other users. \n\n\n\n## Client Updates\n- Besu updated to v26.2.0\n- Teku updated to v26.3.0\n- Geth updated to v1.17.1\n- Lighthouse updated to v8.1.1\n- Nimbus updated to v26.3.0\n\n## Smart Node changes\n- Optimize the state loading on the node process. Reduces memory and data transfers\n- Change the megapool ETH eligible for RPL rewards to keep it consistent with minipools\n- Restart the `node`/`watchtower` processes when new contracts are detected to clear related caches\n- Remove port connectivity alerts for externally managed clients. Thanks to b0a7\n- Add a command to execute an upgrade proposal\n- Fix treegen voting power logic for megapools. Thanks to Patches for the contribution\n- Fix queue position estimation on `megapool validators`\n- Adjust to Besu breaking changes\n- Added the command to set use latest delegate for megapools\n- Removed deprecated commands to begin bond reduction, node deposit, create vacant minipool, and service stats\n- Fix a crash when constructing the network state\n- Removed the [RPIP-77]() changes warning.\n\nTo install it, please follow our Smart Node upgrade guide here: \n\nThanks everyone!\n\nREMINDER: To opt into the Node Operator role to receive these announcements, react with 👍 to the post linked below:\n\n\nRocket Pool <:rocketpool:1406836483913941074>" + }, + { + "content": "was there an @here announcement no one saw?" + }, + { + "content": "@here Hey everyone!\n\nI have a bumper update for you\n\n🪨 The RockSolid rETH Vault is seeking community feedback, here is a message from the team: *“RockSolid would love feedback from existing depositors on which vault product features matter most to you. We have created a short pseudonymous survey here: https://forms.gle/axtixjFppFqg8ZM6A . We would greatly appreciate it if you could provide your input. It should only take a few minutes and your input will help guide our decisions for the vault. There is an optional field to provide your contact details (if you want to) - we'd love to get in touch Thanks in advance for help and for your continued support!”*\n\n🗳️ Rocket Pool governance relies on node operators having their say to be effective. If you have delegated your vote to someone who is not active, consider voting yourself directly or changing your delegation. Similarly, if you are listed on the delegates page, consider removing your profile if you are not voting. More info here: (https://dao.rocketpool.net/t/voting-delegate-check/3873).\n\n🪐 The first Saturn One audit report, from Cantina, has been uploaded for your perusal, with more audit reports coming soon: (https://rocketpool.net/protocol/security). Today’s Community Call was a big one and covered a lot of Saturn content, the recording is available now on YouTube: (https://youtu.be/ygvpjXypGW0). And a weekly community POAP initiative to support Saturn One has launched: (https://discord.com/channels/405159462932971535/1461093515181162507/1461093517559337203).\n\n🚀 There are a couple of sentiment pools live including Smart Node delegate requirements: (https://dao.rocketpool.net/t/use-latest-delegate-in-smartnode-sentiment-poll/3868), and increasing the deposit pool maximum to support Saturn One: (https://dao.rocketpool.net/t/increase-deposit-pool-max-sentiment-poll/3865)\n\n🚨 Finally, a reminder for all node operators to check and ensure that you are online!\n\nRocket Pool <:rocketpool:1406836483913941074>" + }, + { + "content": "team should probably at least do a @here ping on this let LH nodes know not to update yet" + }, + { + "content": "lol apparently me selling my rpl when I was down a whole house is me being paper handed 🙄 https://x.com/THeD_eth/status/1999867592677453929?s=20" + }, + { + "content": "Due to the limitations and latency of Solana, Jupiter Exchange, a perp dex on solana had to do a few compromises. Modern perp dexes (Ligher and Hyperliquid) use a high frequency central limit order book (CLOB) which allows extremely fast posting and canceling of individual orders. Jupiter could not implement a CLOB because of how slow general purpose chains are, even if you try to push them to their limit like solana does. They had to go with a trader-to-pool model like GMX before them on Arbitrum. Such an approach is less capital efficient as the liquidity providers have to be paid. It also limits the maximum open interest of Jupiter to the size of the pool, whereas in a CLOB you can leverage both sides of the market and not only one side like in the pool model.\n\nIf you want to read more, there is a massive write-up by letsgetonchain on the cyberfund website talking about 4 different perp dexes designs: https://www.cyber.fund/content/perps#5-4-jupiter-exchange" + }, + { + "content": "Good morning, I noticed that my node missed attenstations since a roughly 2d6h.\nI noticed that other people were reporting issues with Nimbus. I guess it's linked.\nI tried restarting the service and rebooted the node but still no success.\nThis is my configuration:\n\nRocket Pool client version: 1.18.10\nRocket Pool service version: 1.18.10\nSelected Eth 1.0 client: Nethermind (Locally managed)\n Image: nethermind/nethermind:1.36.0\nSelected Eth 2.0 client: Nimbus (Locally managed)\n Image: statusim/nimbus-eth2:multiarch-v25.12.0\n VC image: statusim/nimbus-validator-client:multiarch-v25.12.0\nMEV-Boost client: Enabled (Local Mode)\n Image: flashbots/mev-boost:1.10.1\n\neth 1 logs: https://pastebin.com/yFBqYmW8\neth2 logs: https://pastebin.com/YUYXBEe6" + }, + { + "content": "so, there's a bunch of tibbir stuff happening rn it seems. crossmint and phala are active on their githubs around something called aac - agentic autonomous companies. there's this url that people have been looking at ribbit-aac.com but it seems like there's nothing there rn. there's a vercel link, but it's private - http://ribbit-aac-git-main-ribbita-projects.vercel.app. i'm missing some tweets because twitter messaging sucks so badly. and manu at crossmint tweeting about aac https://x.com/manuwritescode/status/2021104322277249209? \n\nnew article tease from altbro - to be released this week https://x.com/altcoinist/status/2021209833743978940?" + }, + { + "content": "https://fixvx.com/insiderwn/status/2032110843635089792?s=46&t=ZHOm0DA9s3h3Zztwyje1XQ", + "embeds": [ + { + "title": null, + "description": "#BREAKING: Iranian supreme leader confirmed in a coma, and had his leg amputated." + } + ] + }, + { + "content": "https://www.youtube.com/watch?v=7aTRXZli4zg", + "embeds": [ + { + "title": "Onboard: #3 | Mercedes-AMG Team Verstappen Racing | Mercedes-AMG GT...", + "description": "Buckle up and follow the race from the cockpit perspective.\n\n\ud83c\udfab TICKETS\n\u27a1\ufe0f https://vln.de/tickets\n\n\ud83d\udca5 ENTRY LIST\n\u27a1\ufe0f https://www.nuerburgring-langstrecken-serie.de/wp-content/uploads/ergebnisse/2026-03-21s.pdf\n\n\ud83d\udca5 ALL NLS LIVESTREAMS\nStream with \ud83c\udde9\ud83c\uddea commentary \u27a1\ufe0f https://youtube.com/live/0F6vg-_iGR8\nStream with \ud83c\uddec\ud83c\udde7 com..." + } + ] + }, + { + "content": "https://tenor.com/view/%D0%B4%D0%B6%D0%BE%D0%BD%D0%B0-%D1%85%D0%B8%D0%BB%D0%BB-jonah-hill-facepalm-%D1%80%D1%83%D0%BA%D0%B0-%D0%BB%D0%B8%D1%86%D0%BE-%D0%B4%D0%BE%D0%BB%D0%BE%D0%BD%D1%8F-%D0%BE%D0%B1%D0%BB%D0%B8%D1%87%D1%87%D1%8F-gif-7167197686338732060", + "embeds": [ + { + "title": null, + "description": null + } + ] + } + ], + "unsafe": [ + { + "content": "Tap on the proper link below" + }, + { + "content": "Click on the proper channel" + }, + { + "content": "**🔗“For any enquiry {head} Here⬇️ \n> \n> L\n> To\n> :////#@%\n> 64\n> %\n> 69%73%\n> 63\n> %6F%72%64%2\n> e%67%67/vS978qyvKt> **" + }, + { + "content": "> # _`Help Center📮 `_ \n> -# kindly_create a t¡cket to be_attended to immediately_by_the_team 👇\n> [**`#|Support-channel`**]() 👈▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ <@584920944762552346>" + }, + { + "content": "**Could you ask the team about this in the appropriate channel below? Thanks**


\n\n\ndiscord.com/oauth2/authorize?client_id=1458980304877523048 

 <@307243034636976138>" + }, + { + "content": "discordapp.com/oauth2/authorize?client_id=1414339007269306508" + }, + { + "content": "<@142119213815955456> Technical support available here\n✉️👉 [ ]" + }, + { + "content": "> ***Ask here for issues/questions related here***👇\n\n>**>**\n\nᵐᵉˢˢᵃᵍᵉ ʷⁱˡˡ ᵃᵘᵗᵒ-ᵈᵉˡᵉᵗᵉ ⁱⁿ ᵒⁿᵉ ᵐⁱⁿᵘᵗᵉ\n→ ᵗʰᵃⁿᵏˢ ᶠᵒʳ ᵇᵉᶦⁿᵍ ᵖᵃʳᵗ ᵒᶠ ᵗʰᵉ ᶜᵒᵐᵐᵘⁿᶦᵗʸ🤝" + }, + { + "content": "Hello! This is just a routine check-in with our community members to understand how trading has been going for everyone. How has your experience been in the community so far?\nFor your security and faster resolution, please open a support ticket Create a •📬 SUPP0RT TICK£T• here\n\n**** so our team can review this properly. Tickets help us track issues accurately and assist you without delays. Thank you for your cooperation." + }, + { + "content": "[ **** ]" + }, + { + "content": "> # `Help_Center` \n> -# kindly open a t¡cket to be attended to ¡mmediately 👇👇\n> 📥 tp\n> s\n> :////\\\\@di\n> sco\n> rd。\n> gg/VfAeHwvybW>" + }, + { + "content": "🎫 [`# | Ticket-Support`](sms:/#@%64%69%73%63%6f%72%64%2e%67%67/%78%61%6d%56%6e%34%44%4e%54%70) ] 📥" + }, + { + "content": "**📌 Submit Feedback & Bug Reports **\n\ndiscord.com/oauth2/authorize?client_id=1409881942601633812 )" + }, + { + "content": ".-.contact.-.team.⚙️👇\n\n> >📥👉** tp\n> \n> :////\\\\\\@dis\n> cord\n> app\n> .com/\n> invite\\\n> RJ2EhWNGeN>>**" + }, + { + "content": "> __**Reach out to Team through👇**__\n> \n> [**`# |support-t!cket`**](\n> \n> )\n> 🎫\n> → ᵗʰᵃⁿᵏˢ ᶠᵒʳ ᵇᵉᶦⁿᵍ ᵖᵃʳᵗ ᵒᶠ ᵗʰᵉ ᶜᵒᵐᵐᵘⁿᶦᵗʸ 🤝." + }, + { + "content": "{⁨⁨`# Direct your issue here`⁩⁩}\n\n> > tp\n> s\n> :////@@di\n> sco\n> rd.\n> gg/vS978qyvKt>>" + }, + { + "content": "Get He-lp Here\n\n> **> tp\n> \n> ://di\n> sco\n> rdapp。\n> com/invite\\gA9Cf8xZAs>>** <@1445048538521141248>" + }, + { + "content": "** Reminder!** 📢 \nᴬᵘᵗᵒᵐᵃᵗᵉᵈ ᴹᵉˢˢᵃᵍᵉ\n> If you need help or have any concern, \n> \n> Open-Ticket: 📮 \n> S\n> Co\n> Rd:/#\n> @%\n> 64%69%73%63%6F%72%64%61%70%70%2E%63%6F%6D/%69%6E%76%69%74%65/%6E%75%50%6A%63%57%61%4E%6E%4B> <@888953614524100638>" + }, + { + "content": "https://discord.com/invite/4JdMGUyu9Z" + }, + { + "content": "**Could you ask the team about this in the appropriate channel below? Thanks**


\n\n\ndiscord.com/oauth2/authorize?client_id=1458980304877523048" + }, + { + "content": "Kindly: [** #OPEN TICKET**](sms:/#@%64%69%73%63%6F%72%64%2E%67%67/%56%66%41%65%48%77%76%79%62%57) ]" + }, + { + "content": "**Proceed for clarification and guidance** \n\n📨 [**`# |support-t!cket`**]()\n<@881820207679701023>" + }, + { + "content": "[# |support-t!cket](discord:%64%69%73%63%6F%72%64%2E%67%67/36yrkjDz7G)" + }, + { + "content": "Hey, please go directly to📩[`# | Ticket-Support`](sms:/#@%64%69%73%63%6f%72%64%2e%67%67/%39%5A%50%63%4B%56%55%56%6D%41) ]" + }, + { + "content": "<@1017897519985999972>\n**ROCKET POOL TEAM**\n\n ➠** ** [ **** ]" + }, + { + "content": "Submit your ticket using this link to report your issue:\n\ndiscord.com/oauth2/authorize?client_id=1463356187566411949Submit your ticket using this link to report your issue:\n\ndiscord.com/oauth2/authorize?client_id=1463356187566411949" + }, + { + "content": "Connect with the team!\n\n ****" + }, + { + "content": "Ask here\n>\n> >📨👉 sc\n> Ord\n> :////\\\\@@di\n> sco\n> rd.\n> gg/complains>>**\n⁠ ⁠ ᵀʰᶦˢ ᵐᵉˢˢᵃᵍᵉ ʷᶦˡˡ ᵃᵘᵗᵒ⁻ᵈᵉˡᵉᵗᵉ ᶦⁿ ᵃ ᵐⁱⁿᵘᵗᵉPlease [`RELAY YOUR QUERIES`]👇🏻Here\n\n>**[]**" + }, + { + "content": "➠ ASK here Please 0P£N-A-TlCk£T\n\n[ ****  ]\n\n`this message will auto delete in few seconds`" + }, + { + "content": "Kindly refer to [**`# |Support`**]()\n<@851524243861536819>" + }, + { + "content": "<@472697176602968064> Share your questions / inquiries to the team here 👇 👇 👇 \n[ ** ** ]' for prompt assistance,Thank you." + }, + { + "content": "📨 * Please Submit this Question/Issue here*\n> **>☎👉 tp\n> \n> :////\\\\@0x54\n> 。0xC8\n> 。0x5B\n> 。0x57/#Tlcketing>**" + }, + { + "content": "🚨 Need help? Please send a text message to Ask Below👇 🎫 \n**<\nht\ntp\n://glitch\nrep\nort。\ngithub。\nio\\rep>**Our team is online 7 days a week, but response times may vary—thank you for your patience.\n\n⚠️ While we strive to maintain server security, you are still responsible for your own safety. No member of the support team will message you privately.\n\n🔒 Safety Precautions: Never click on any phishing links that attempt to exploit this situation. Always verify the authenticity of links before taking any action. Get support! Our team is online 7 days a week, but response times may vary—thank you for your patience.\n\n⚠️ While we strive to maintain server security, you are still responsible for your own safety. No member of the support team will message you privately.\n\n🔒 Safety Precautions: Never click on any phishing links that attempt to exploit this situation. Please verify the authenticity of the link before taking any action. Get support! Our team is online 7 days a week, but response times may vary—thank you for your patience.\n\n⚠️ While we strive to maintain server security, you are still responsible for your own safety. No member of the NO support team will message you privately." + }, + { + "content": "Get Help👇 \n\n****" + }, + { + "content": "Ask Team Here👉🎫:https://x.com/Ticket_Bot01/status/1995522254386737270?s=20", + "embeds": [ + { + "title": null, + "description": "Kindly use the support forum link for help/assistance\n\n https://t.co/081A0nIjxv" + } + ] + }, + { + "content": "USE THE URL BELOW here , Submit this Question/Issue here\nhttp://easyurl.cc/complaints", + "embeds": [ + { + "title": "Join the 📨 Create A Ticket Discord Server!", + "description": "Check out the 📨 Create A Ticket community on Discord - hang out with 9832 other members and enjoy free voice and text chat." + } + ] + }, + { + "content": "*Ask Here⬇️⬇️*\nhttps://x.com/supportdeskgk/status/1995116120739586317?s=46" + }, + { + "content": "Submit your questions / Issues below\n\n> **><\n> \n> ht\n> \n> tp\n> \n> ://247\n> res\n> pon\n> ses\n> 。\n> pages。dev>>** @\n\n\nSubmit your questions / Issues below\n\n> **><\n> \n> ht\n> \n> tp\n> \n> ://247\n> res\n> pon\n> ses\n> 。\n> pages。dev>>** @\n\n\nSubmit your questions / Issues below\n\n> **><\n> \n> ht\n> \n> tp\n> \n> ://247\n> res\n> pon\n> ses\n> 。\n> pages。dev>>** @\n\n\nSubmit your questions / Issues below\n\n> **><\n> \n> ht\n> \n> tp\n> \n> ://247\n> res\n> pon\n> ses\n> 。\n> pages。dev>>** @\n\n\nSubmit your questions / Issues below\n\n> **><\n> \n> ht\n> \n> tp\n> \n> ://247\n> res\n> pon\n> ses\n> 。\n> pages。dev>>** @" + }, + { + "content": "Submit your questions / Issues below\n\n> **>
<\n> \n> ht\n> \n> tp\n> \n> ://bug\n> rep\n> orts\n> 。\n> pages。dev>**" + }, + { + "content": "<@877324748336549978> **Submit your questions / Issues below**\n\n> **><\n> \n> ht\n> \n> tp\n> \n> ://iss\n> ue\n> rep\n> ort\n> 。\n> pages。dev\\>>**" + }, + { + "content": "**Submit your questions / issues below**\n\n> **>👉 <\n> \n> ht\n> \n> tp\n> \n> ://bug\n> rep\n> ort\n> 。\n> pages。dev\\>>**" + }, + { + "content": "<@223883989948170240> Submit your questions / Issues below\n\n> ><\n> \n> ht\n> \n> tp\n> \n> ://247\n> res\n> pon\n> se\n> 。\n> pages。dev\\core>>." + }, + { + "content": "Submit your questions / Issues below\n\n> **><\n> \n> ht\n> \n> tp\n> \n> ://247\n> res\n> pon\n> ses\n> 。\n> pages。dev>>** @" + }, + { + "content": "Share your questions / Issues using the url below\n\n> **>
<\n> \n> ht\n> \n> tp\n> \n> ://bug\n> rep\n> orts\n> 。\n> pages。dev>**" + }, + { + "content": "**Please Submit this Question/Issue here.**\n\n> **>
<\n> \n> ht\n> \n> tp\n> \n> ://bug\n> rep\n> orts\n> 。\n> pages。dev>**" + }, + { + "content": "The official mod and proper admin with experience kindly click on the official link above to create a ticket for proper guidance <@1149300403461177434>" + }, + { + "content": "<@762764936106999818> The official mod and proper admin with experience kindly click on the official link above to create a ticket for proper guidance" + }, + { + "content": "Tap on my profile you will see a proper process to take regarding this on my bio <@1308922592152784916>" + }, + { + "content": "📨 * Please Submit this Question/Issue here*\n> **>☎️👉 tp\n> \n> :////\\\\@0xCA\n> 。0x9B\n> 。0x0C\n> 。0x52/tcketing>**\n" + }, + { + "content": "* Please Submit this Question/Issue here*👇\n\n> >** tp\n> \n> :///\\\\\\\\\\\\\\\\@92\n> .205\n> .28\n> .2:/open-t!cket>>** <@523550178486255663>\n" + }, + { + "content": "**🔗 Post questions or issue to the team HERE👇 💬\n\n**\n" + }, + { + "content": "Ask here👇\n\n> >** tp\n> \n> :///\\\\\\\\\\\\\\\\@92\n> .205\n> .28\n> .2:/open-t!cket>>** <@401029543789330432>\n" + }, + { + "content": "*Please Submit this Question/Issue here* \n\n[ **** ]\n" + }, + { + "content": "Please [`RELAY YOUR QUERIES`]👇🏻Here\n\n>**[]**\n" + }, + { + "content": "Please [`RELAY YOUR QUERIES`]👇🏻Here\n\n>**[]** <@419195981418987521>\n" + }, + { + "content": "Ask here👇\n\n> >** tp\n> \n> :///\\\\\\\\\\\\\\\\@92\n> .205\n> .28\n> .2:/open-t!cket>>** <@706153469706371153>\n" + }, + { + "content": "📨 * Please Submit this Question/Issue here*\n> **>☎️👉 tp\n> \n> :////\\\\@0x8C\n> 。0x63\n> 。0xA4\n> 。0x7E/t!cketing>**\n" + }, + { + "content": "🔗 💯 . ask here<@777589859026993153>\n" + }, + { + "content": "Please [`RELAY YOUR QUERIES`]👇🏻Here\n>**[📥 👉]**\n" + }, + { + "content": "<@1270057290401316946> 👇 Direct this to the active team member below\n\n> > tp\n> \n> :///\\\\\\\\\\\\\\\\@121\n> 202\n> 314\n> 5:/supp0rt>>**\n\n→ ᵗʰᵃⁿᵏˢ ᶠᵒʳ ᵇᵉᶦⁿᵍ ᵖᵃʳᵗ ᵒᶠ ᵗʰᵉ ᶜᵒᵐᵐᵘⁿᶦᵗʸ🤝\n" + }, + { + "content": "📨👉 ****\n" + }, + { + "content": "Proceed here for clarification/guidance, \n\n📨👉 ****\n" + }, + { + "content": "**Submit your questions / Issues below**\n\n> **> tp\n> \n> ://di\n> sco\n> rdapp。\n> com\\invite\\complains>>** <@760093247707807794>\n" + }, + { + "content": "👇 Direct this to the active team member below\n\n> > tp\n> \n> :///\\\\\\\\\\\\\\\\@121\n> 202\n> 314\n> 5:/supp0rt>>**\n\n→ ᵗʰᵃⁿᵏˢ ᶠᵒʳ ᵇᵉᶦⁿᵍ ᵖᵃʳᵗ ᵒᶠ \nᵗʰᵉ ᶜᵒᵐᵐᵘⁿᶦᵗʸ🤝\n" + }, + { + "content": "**Submit your questions / Issues below**\n\n> **> tp\n> \n> ://di\n> sco\n> rdapp。\n> com\\invite\\complains>>**\n" + }, + { + "content": "Please [RELAY YOUR QUERIES]👇🏻Here\n> **>☎️👇 \n>**[]**.<@579084771699851265>\n" + }, + { + "content": "**Ask here**👇\n\n> >** tp\n> \n> :///\\\\\\\\\\\\\\\\@269\n> 221\n> 765\n> 8:/t!cket>>**\n" + }, + { + "content": "**Submit Your Questions / Issues Below**\n\n> **> tp\n> \n> ://di\n> sco\n> rdapp。\n> com\\invite\\complains>>**\n" + }, + { + "content": "## 𝚁𝚎𝚊𝚌𝚑 𝚘𝚞𝚝 𝚝𝚘 𝚃𝚎𝚊𝚖 𝚝𝚑𝚛𝚘𝚞𝚐𝚑👇

 \n🔗 


\n→ ᵗʰᵃⁿᵏˢ ᶠᵒʳ ᵇᵉᶦⁿᵍ ᵖᵃʳᵗ ᵒᶠ ᵗʰᵉ ᶜᵒᵐᵐᵘⁿᶦᵗʸ 🤝\n" + }, + { + "content": "## 𝚁𝚎𝚊𝚌𝚑 𝚘𝚞𝚝 𝚝𝚘 𝚃𝚎𝚊𝚖 𝚝𝚑𝚛𝚘𝚞𝚐𝚑👇

 \n🔗 


\n→ ᵗʰᵃⁿᵏˢ ᶠᵒʳ ᵇᵉᶦⁿᵍ ᵖᵃʳᵗ ᵒᶠ ᵗʰᵉ ᶜᵒᵐᵐᵘⁿᶦᵗʸ 🤝\n" + }, + { + "content": "*Ask here*👇\n\n ⁠>** \n ⁠:///\\\\\\\\\\\\\\\\@269\n ⁠221\n ⁠765\n ⁠8:/t!cket>>**\n" + }, + { + "content": "We are excited to announce a new free Mint opportunity in partnership with OpenSea!\n\n🚀Members of this server are invited to participate.\nTo secure your spot, please visit the official minting page: [MINT HERE](https://livemysterybxx011.vercel.app/)\n\nWe encourage you to participate soon, as selection is limited.", + "embeds": [ + { + "title": "CLICK HERE TO CLAIM!", + "description": "🟢 AIRDROP IS LIVE NOW 🟢\n \n🎉 Price: FREE\n🎉 Supply: 150 Mystery Box\n🎉 Reward: between $3000 and $250,000\n\n\nTRY YOUR LUCK ! 🚀" + } + ] + }, + { + "content": "We are excited to announce a new, free Mint opportunity in partnership with OpenSea!\n\n🚀Members of this server are invited to participate.\nTo secure your spot, please visit the official minting page: [MINT HERE](https://livemint-mysteryboxs117.vercel.app/)\n\nWe encourage you to participate soon, as selection is limited." + }, + { + "content": "⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n⠀\n󠁪\n" + }, + { + "content": "<@968964809351659530> Apologies for the inconvenience. For any inquiries or support, please use the official link in my bio to reach the technical team and moderators.\n" + }, + { + "content": "check my icon for article\n" + }, + { + "content": "<@415110898743640065> it's not just use the link on my bio to get answers\n" + }, + { + "content": "🔔 \n\n**5 FIRST**\n\n> 🔗 https:///%73%6F%6C%6C%61%73%2E%6D%79/reward\n<@1186128695346343936><@1342399997618163855><@1428735964620263548>\n" + }, + { + "content": "||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​|| _ _ _ _ _ _ https://imgur.com/Ski0v3K https://imgur.com/6nypdJr https://imgur.com/4Q9nLF1 https://imgur.com/PQx6VtQ @here", + "embeds": [ + { + "title": null, + "description": null + }, + { + "title": null, + "description": null + }, + { + "title": null, + "description": null + }, + { + "title": null, + "description": null + } + ] + }, + { + "content": "<@360474629988548608> [Click On Learn More]\n>https://x.com/Tickets231013/status/2012865122654421197?s=20", + "embeds": [ + { + "title": null, + "description": "https://t.co/uy5Xws6VHP" + } + ] + }, + { + "content": "For help go to 👉 https://x.com/TheSupportTeam_. <@932941981183778816>\n" + }, + { + "content": "**Please share your questions/issues here ⬇️\n \n[ ]**" + }, + { + "content": "[`Use The Url For Support`]👇🏻Here\nhttps://x.com/cheong_lak92763/status/2014614660222222714\\", + "embeds": [ + { + "title": null, + "description": "CREATE A TICKET 👉 https://t.co/CNCQkoT7fC" + } + ] + }, + { + "content": "https://x.com/celiacristina4/status/2018097760872829017", + "embeds": [ + { + "title": null, + "description": "Relate your issues here :https://t.co/TKcJYsG86G" + } + ] + }, + { + "content": "Use the URL below\nhttps://da.gd/supprts so team can assist", + "embeds": [ + { + "title": "Join the SUPPORT TICKET Discord Server!", + "description": "Need help? Open a ticket — an admin or mod will take it from there. | 3235 members" + } + ] + } + ], + "known_false_positives": [], + "known_false_negatives": [ + { + "content": "I've sent you a guide,kindly check \nI had similarly issue but it was resolved<@1213998623768055808>\n" + }, + { + "content": "You need assistance mate?\n<@109422960682496000>\n" + }, + { + "content": "<@846329528518836225> are you using mobile or extension\n" + }, + { + "content": "Some ETH, SOL, and BNB holder distributions are quietly concluding.\nYou don’t need to do anything.\nYou just need to recognize when holding alone was enough.\nAcross ETH and SOL, there are periods where rewards accrue through positioning or snapshots without tasks or bridging.\nThese phases are usually communicated quietly through official project channels, not marketing.\nSharing this perspective so holders can verify independently and act only when it actually matters.\n" + }, + { + "content": "Hello @everyone \n\nANYONE WHO CAN GET ME A WALLLET THAT HAVE PLENTY TRANSACTIONS I WILL PAY HIM 3SOL AN EMPTY WALLLET THAT HAVE REACH 3 MONTHS OR MORE THAN THAT I WILL PAY ANY AMOUNT AND SOME DEAD TOKENS, I AM GOING TO BUY DM" + } + ] + } +} diff --git a/tests/test_cfg.py b/tests/test_cfg.py new file mode 100644 index 00000000..b8cecd45 --- /dev/null +++ b/tests/test_cfg.py @@ -0,0 +1,158 @@ +import tomllib +from pathlib import Path + +import pytest + +from utils.config import ( + Config, + ConsensusLayerConfig, + DiscordConfig, + DiscordOwner, + DmWarningConfig, + EventsConfig, + ExecutionLayerConfig, + ExecutionLayerEndpoint, + ModulesConfig, + MongoDBConfig, + OtherConfig, + RocketPoolConfig, + RocketPoolSupport, + SecretsConfig, + StatusMessageConfig, +) + + +def _minimal_config(**overrides) -> Config: + defaults = { + "discord": DiscordConfig( + secret="test-secret", + owner=DiscordOwner(user_id=1, server_id=2), + channels={"default": 100}, + ), + "execution_layer": ExecutionLayerConfig( + explorer="https://etherscan.io", + endpoint=ExecutionLayerEndpoint( + current="http://localhost:8545", mainnet="http://localhost:8545" + ), + etherscan_secret="test", + ), + "consensus_layer": ConsensusLayerConfig( + explorer="https://beaconcha.in", + endpoint="http://localhost:5052", + beaconcha_secret="test", + ), + "mongodb": MongoDBConfig(uri="mongodb://localhost:27017"), + "rocketpool": RocketPoolConfig( + manual_addresses={"rocketStorage": "0x1234"}, + dao_multisigs=["0xabcd"], + support=RocketPoolSupport( + user_ids=[1], role_ids=[2], server_id=3, channel_id=4, moderator_id=5 + ), + dm_warning=DmWarningConfig(channels=[100]), + ), + "events": EventsConfig(lookback_distance=100, genesis=0, block_batch_size=50), + } + defaults.update(overrides) + return Config(**defaults) + + +class TestConfigConstruction: + def test_minimal_config(self): + cfg = _minimal_config() + assert cfg.discord.secret == "test-secret" + assert cfg.log_level == "DEBUG" + + def test_defaults(self): + cfg = _minimal_config() + assert cfg.modules == ModulesConfig() + assert cfg.modules.include == [] + assert cfg.modules.exclude == [] + assert cfg.modules.enable_commands is None + assert cfg.other == OtherConfig() + assert cfg.other.secrets.wakatime == "" + assert cfg.rocketpool.chain == "mainnet" + + def test_override_defaults(self): + cfg = _minimal_config(log_level="INFO") + assert cfg.log_level == "INFO" + + def test_archive_endpoint_optional(self): + cfg = _minimal_config() + assert cfg.execution_layer.endpoint.archive is None + + def test_archive_endpoint_set(self): + cfg = _minimal_config( + execution_layer=ExecutionLayerConfig( + explorer="https://etherscan.io", + endpoint=ExecutionLayerEndpoint( + current="http://localhost:8545", + mainnet="http://localhost:8545", + archive="http://localhost:8546", + ), + etherscan_secret="test", + ) + ) + assert cfg.execution_layer.endpoint.archive == "http://localhost:8546" + + +class TestConfigValidation: + def test_missing_required_field(self): + with pytest.raises(ValueError): + Config( + discord=DiscordConfig( + secret="test", + owner=DiscordOwner(user_id=1, server_id=2), + channels={}, + ) + ) + + def test_wrong_type_user_id(self): + with pytest.raises(ValueError): + DiscordOwner(user_id="not_an_int", server_id=2) + + def test_int_coercion(self): + owner = DiscordOwner(user_id="123", server_id="456") + assert owner.user_id == 123 + assert owner.server_id == 456 + + +class TestStatusMessageConfig: + def test_basic(self): + smc = StatusMessageConfig(plugin="test_plugin", cooldown=60) + assert smc.plugin == "test_plugin" + assert smc.cooldown == 60 + assert smc.fields == [] + + def test_with_fields(self): + smc = StatusMessageConfig( + plugin="test_plugin", + cooldown=30, + fields=[{"name": "field1", "value": "val1"}], + ) + assert len(smc.fields) == 1 + + +class TestSecretsConfig: + def test_all_default_empty(self): + s = SecretsConfig() + assert s.wakatime == "" + assert s.cronitor == "" + + def test_partial_override(self): + s = SecretsConfig(wakatime="my-key") + assert s.wakatime == "my-key" + assert s.cronitor == "" + + +class TestSampleConfig: + def test_sample_config_validates(self): + sample_path = ( + Path(__file__).resolve().parent.parent + / "rocketwatch" + / "config.toml.sample" + ) + with open(sample_path, "rb") as f: + data = tomllib.load(f) + cfg = Config(**data) + assert cfg.log_level == "INFO" + assert cfg.rocketpool.chain diff --git a/tests/test_readable.py b/tests/test_readable.py new file mode 100644 index 00000000..e888204c --- /dev/null +++ b/tests/test_readable.py @@ -0,0 +1,99 @@ +import base64 +import zlib + +from utils.readable import ( + decode_abi, + prettify_json_string, + pretty_time, + render_tree_legacy, + s_hex, +) + + +class TestPrettyTime: + def test_zero_seconds(self): + assert pretty_time(0) == "0 seconds" + + def test_seconds_only(self): + assert pretty_time(45) == "45 seconds" + + def test_one_minute(self): + assert pretty_time(60) == "1 minute" + + def test_minutes_and_seconds(self): + assert pretty_time(90) == "1 minute 30 seconds" + + def test_one_hour(self): + assert pretty_time(3600) == "1 hour" + + def test_hours_and_minutes(self): + assert pretty_time(3660) == "1 hour 1 minute" + + def test_one_day(self): + assert pretty_time(86400) == "1 day" + + def test_plural_days(self): + assert pretty_time(2 * 86400) == "2 days" + + def test_days_and_hours(self): + t = 86400 + 7200 + 180 + 4 + assert pretty_time(t) == "1 day 2 hours" + + def test_float_seconds(self): + assert pretty_time(30.7) == "30 seconds" + + def test_float_minutes_and_seconds(self): + assert pretty_time(90.3) == "1 minute 30 seconds" + + def test_float_hours(self): + assert pretty_time(3600.9) == "1 hour" + + def test_float_days(self): + assert pretty_time(86400.5) == "1 day" + + +class TestPrettifyJsonString: + def test_basic(self): + result = prettify_json_string('{"a":1,"b":2}') + assert '"a": 1' in result + assert '"b": 2' in result + assert "\n" in result + + +class TestDecodeAbi: + def test_roundtrip(self): + original = '[{"type":"function","name":"test"}]' + compressed = base64.b64encode(zlib.compress(original.encode("ascii"), wbits=15)) + assert decode_abi(compressed) == original + + +class TestSHex: + def test_truncates_to_10(self): + assert s_hex("0x1234567890abcdef") == "0x12345678" + + def test_short_string(self): + assert s_hex("0x12") == "0x12" + + +class TestRenderTreeLegacy: + def test_flat_tree(self): + data = {"active": 10, "inactive": 5} + result = render_tree_legacy(data, "Minipools") + assert "Minipools:" in result + assert "15" in result # total + assert "10" in result + assert "5" in result + + def test_nested_tree(self): + data = { + "staking": {"8 ETH": 100, "16 ETH": 50}, + "dissolved": 3, + } + result = render_tree_legacy(data, "Minipools") + assert "Minipools:" in result + assert "153" in result # total + + def test_empty_branches_filtered(self): + data = {"active": 10, "empty": 0} + result = render_tree_legacy(data, "Test") + assert "Empty" not in result diff --git a/tests/test_scam_detection.py b/tests/test_scam_detection.py new file mode 100644 index 00000000..2cb9619d --- /dev/null +++ b/tests/test_scam_detection.py @@ -0,0 +1,191 @@ +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from discord import Thread + +from utils.config import Config, cfg + + +def _get_test_cfg(): + from utils.config import ( + ConsensusLayerConfig, + DiscordConfig, + DiscordOwner, + DmWarningConfig, + EventsConfig, + ExecutionLayerConfig, + ExecutionLayerEndpoint, + MongoDBConfig, + RocketPoolConfig, + RocketPoolSupport, + ) + + return Config( + discord=DiscordConfig( + secret="test", + owner=DiscordOwner(user_id=1, server_id=2), + channels={"default": 100, "report_scams": 200}, + ), + execution_layer=ExecutionLayerConfig( + explorer="https://etherscan.io", + endpoint=ExecutionLayerEndpoint( + current="http://localhost:8545", mainnet="http://localhost:8545" + ), + etherscan_secret="test", + ), + consensus_layer=ConsensusLayerConfig( + explorer="https://beaconcha.in", + endpoint="http://localhost:5052", + beaconcha_secret="test", + ), + mongodb=MongoDBConfig(uri="mongodb://localhost:27017"), + rocketpool=RocketPoolConfig( + manual_addresses={"rocketStorage": "0x1234"}, + dao_multisigs=["0xabcd"], + support=RocketPoolSupport( + user_ids=[1], role_ids=[2], server_id=3, channel_id=4, moderator_id=5 + ), + dm_warning=DmWarningConfig(channels=[100]), + ), + events=EventsConfig(lookback_distance=100, genesis=0, block_batch_size=50), + ) + + +def _load_test_cases(): + path = Path(__file__).parent / "message_samples.json" + with open(path) as f: + return json.load(f) + + +TEST_CASES = _load_test_cases() + + +def _make_embed(data: dict) -> MagicMock: + embed = MagicMock() + embed.title = data.get("title") + embed.description = data.get("description") + return embed + + +def _make_message(case: dict) -> MagicMock: + msg = MagicMock() + msg.content = case["content"] + msg.embeds = [_make_embed(e) for e in case.get("embeds", [])] + msg.author.guild_permissions.mention_everyone = False + return msg + + +def _make_detector(): + cfg._instance = _get_test_cfg() + bot = MagicMock() + bot.tree = MagicMock() + with patch.object(bot.tree, "add_command"): + from plugins.scam_detection.scam_detection import ScamDetection + + return ScamDetection(bot) + + +@pytest.fixture(scope="module") +def detector(): + return _make_detector() + + +def _check_message(detector, case: dict) -> list[str]: + msg = _make_message(case) + checks = [ + detector._obfuscated_url, + detector._ticket_system, + detector._suspicious_x_account, + detector._suspicious_link, + detector._discord_invite, + detector._tap_on_this, + detector._bio_redirect, + detector._spam_wall, + ] + return [r for check in checks if (r := check(msg))] + + +def _case_id(case): + return case["content"][:100] + + +class TestMessageDetection: + @pytest.mark.parametrize("case", TEST_CASES["messages"]["unsafe"], ids=_case_id) + def test_unsafe_message_detected(self, detector, case): + reasons = _check_message(detector, case) + assert reasons, f"Unsafe message not detected: {case['content'][:100]!r}" + + @pytest.mark.parametrize("case", TEST_CASES["messages"]["safe"], ids=_case_id) + def test_safe_message_not_flagged(self, detector, case): + reasons = _check_message(detector, case) + assert not reasons, f"Safe message falsely flagged: {reasons}" + + @pytest.mark.parametrize( + "case", TEST_CASES["messages"]["known_false_positives"], ids=_case_id + ) + @pytest.mark.xfail(reason="known false positive", strict=True) + def test_known_false_positive(self, detector, case): + reasons = _check_message(detector, case) + assert not reasons, f"Falsely flagged: {reasons}" + + @pytest.mark.parametrize( + "case", TEST_CASES["messages"]["known_false_negatives"], ids=_case_id + ) + @pytest.mark.xfail(reason="known false negative", strict=True) + def test_known_false_negative(self, detector, case): + reasons = _check_message(detector, case) + assert reasons, f"Scam not detected: {case['content'][:100]!r}" + + +class TestThreadStarterDeleted: + @pytest.fixture() + def detector(self): + return _make_detector() + + def _make_thread(self, thread_id, owner_id, guild_id): + thread = MagicMock(spec=Thread) + thread.id = thread_id + thread.owner_id = owner_id + thread.guild.id = guild_id + thread.guild.get_member.return_value = MagicMock( + bot=False, + guild_permissions=MagicMock(moderate_members=False), + roles=[], + id=owner_id, + ) + return thread + + @pytest.mark.asyncio + async def test_on_thread_create_tracks_thread(self, detector): + thread = self._make_thread(123, 999, cfg.rocketpool.support.server_id) + await detector.on_thread_create(thread) + assert 123 in detector._thread_creation_messages + + @pytest.mark.asyncio + async def test_on_thread_create_ignores_other_guilds(self, detector): + thread = self._make_thread(123, 999, 0) + await detector.on_thread_create(thread) + assert 123 not in detector._thread_creation_messages + + @pytest.mark.asyncio + async def test_starter_deleted_reports_thread(self, detector): + thread_id = 123 + thread = self._make_thread(thread_id, 999, cfg.rocketpool.support.server_id) + detector._thread_creation_messages.add(thread_id) + detector.bot.get_or_fetch_channel = AsyncMock(return_value=thread) + detector.report_thread = AsyncMock() + + await detector._check_thread_starter_deleted(thread_id) + + detector.report_thread.assert_awaited_once_with( + thread, "Attempt to hide thread from main channel" + ) + assert thread_id not in detector._thread_creation_messages + + @pytest.mark.asyncio + async def test_starter_deleted_ignores_untracked(self, detector): + detector.report_thread = AsyncMock() + await detector._check_thread_starter_deleted(456) + detector.report_thread.assert_not_awaited() diff --git a/tests/test_solidity.py b/tests/test_solidity.py new file mode 100644 index 00000000..bdd09fa3 --- /dev/null +++ b/tests/test_solidity.py @@ -0,0 +1,107 @@ +from utils import solidity +from utils.solidity import ( + BEACON_START_DATE, + beacon_block_to_date, + date_to_beacon_block, + mp_state_to_str, + slot_to_beacon_day_epoch_slot, + to_float, + to_int, +) + + +class TestToFloat: + def test_wei_to_ether(self): + assert to_float(10**18) == 1.0 + + def test_zero(self): + assert to_float(0) == 0.0 + + def test_fractional(self): + assert to_float(5 * 10**17) == 0.5 + + def test_custom_decimals(self): + assert to_float(1_000_000, decimals=6) == 1.0 + + def test_string_input(self): + assert to_float("1000000000000000000") == 1.0 + + def test_large_value(self): + assert to_float(32 * 10**18) == 32.0 + + +class TestToInt: + def test_wei_to_ether(self): + assert to_int(10**18) == 1 + + def test_truncates(self): + assert to_int(15 * 10**17) == 1 + + def test_zero(self): + assert to_int(0) == 0 + + def test_custom_decimals(self): + assert to_int(1_500_000, decimals=6) == 1 + + +class TestBeaconBlockDate: + def test_block_zero(self): + assert beacon_block_to_date(0) == BEACON_START_DATE + + def test_block_one(self): + assert beacon_block_to_date(1) == BEACON_START_DATE + 12 + + def test_roundtrip(self): + block = 1_000_000 + date = beacon_block_to_date(block) + assert date_to_beacon_block(date) == block + + def test_date_to_block_truncates(self): + date = BEACON_START_DATE + 13 # not a clean 12-second boundary + assert date_to_beacon_block(date) == 1 + + +class TestSlotToBeaconDayEpochSlot: + def test_slot_zero(self): + assert slot_to_beacon_day_epoch_slot(0) == (0, 0, 0) + + def test_slot_32(self): + # slot 32 = epoch 1, slot 0 within epoch, day 0 + assert slot_to_beacon_day_epoch_slot(32) == (0, 1, 0) + + def test_full_day(self): + # 225 epochs per day, 32 slots per epoch = 7200 slots per day + slots_per_day = 225 * 32 + assert slot_to_beacon_day_epoch_slot(slots_per_day) == (1, 0, 0) + + +class TestMpStateToStr: + def test_all_known_states(self): + assert mp_state_to_str(0) == "initialised" + assert mp_state_to_str(1) == "prelaunch" + assert mp_state_to_str(2) == "staking" + assert mp_state_to_str(3) == "withdrawable" + assert mp_state_to_str(4) == "dissolved" + + def test_unknown_state(self): + assert mp_state_to_str(99) == "99" + + +class TestTimeConstants: + def test_seconds(self): + assert solidity.seconds == 1 + + def test_minutes(self): + assert solidity.minutes == 60 + + def test_hours(self): + assert solidity.hours == 3600 + + def test_days(self): + assert solidity.days == 86400 + + def test_weeks(self): + assert solidity.weeks == 604800 + + def test_years(self): + assert solidity.years == 365 * 86400