diff --git a/.cursor/rules/coding-conventions.mdc b/.cursor/rules/coding-conventions.mdc new file mode 100644 index 0000000..bb45296 --- /dev/null +++ b/.cursor/rules/coding-conventions.mdc @@ -0,0 +1,21 @@ +--- +description: Coding conventions for stvaults-watcher +globs: src/**/*.js +alwaysApply: false +--- + +# Coding conventions + +- **Language**: JavaScript ESM (`"type": "module"` in package.json). No TypeScript in `src/**/*.js`. **`src/grafana/**` is TypeScript** (Grafana Foundation SDK); run `npm run grafana:build` for `grafana/dashboard.json`. +- **Runtime**: Node.js 24+. Use native `fetch`, `crypto`, `http` - avoid unnecessary dependencies. +- **On-chain reads**: Use viem `readContract` with human-readable ABIs from `src/abis.js`. +- **Metrics**: prom-client Gauges/Counters. Labels: `vault`, `vault_name`, `chain`. Values in ETH (divide wei by 1e18), not raw wei. +- **Logs**: Use `console.log('INFO msg')` / `console.log('WARN msg')` / `console.log('ERROR msg')`. Always use `console.log` (never `console.warn`/`console.error`) so all output goes to stdout. This ensures Docker labels all lines as `stream=stdout` and Loki can detect the level reliably. +- **Comments**: Only for non-obvious intent. English for code (variables, functions, comments). User-facing messages (Discord alerts) in English. +- **Config**: All configuration via env vars loaded in `src/config.js`. Required vars throw on missing. Optional vars have defaults. +- **Error handling**: WithdrawalQueue reads wrapped in try/catch (contract may revert when queue is empty). Individual Discord alert sends wrapped in `.catch()` to not break the poll loop. +- **No write operations**: This watcher is read-only. Never send transactions. + +## Living documentation + +When conventions change, update this file and `.cursor/rules/project-overview.mdc` accordingly. These rules must always reflect the current state of the project. diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc new file mode 100644 index 0000000..644b7b7 --- /dev/null +++ b/.cursor/rules/project-overview.mdc @@ -0,0 +1,136 @@ +--- +description: stvaults-watcher project overview and architecture +alwaysApply: true +--- + +# stvaults-watcher + +Node.js 24 ESM app that monitors Lido V3 stVaults with DeFi Wrapper pools. Runs in Docker, exposes Prometheus metrics, sends Discord alerts. + +## Architecture + +- **Polling loop** (`src/index.js`): Every `POLL_INTERVAL_MIN` minutes reads on-chain data via viem, updates Prometheus gauges, evaluates alert conditions, sends Discord webhooks. +- **No CLI dependency**: All reads are direct contract calls via viem. Lido CLI is only used by the operator separately for write operations (finalize withdrawals, submit reports, exit validators). + +## Key files + +| File | Purpose | +|------|---------| +| `src/index.js` | Entry point, polling loop, alert evaluation, graceful shutdown | +| `src/config.js` | Loads and validates env vars; resolves network constants from `networks.json` | +| `src/networks.json` | Per-network constants: chainId, explorerUrl, Lido contract addresses (vaultHub, stEth, pdg) | +| `src/chain.js` | Creates viem `publicClient` | +| `src/abis.js` | Human-readable ABIs (one-liner per method) for VaultHub, StakingVault, WithdrawalQueue, Pool, stETH | +| `src/monitors/vaultMonitor.js` | Orchestrator: reads all contract data, updates metrics, returns snapshots for alerting | +| `src/monitors/healthMonitor.js` | Health factor and utilization ratio computation | +| `src/monitors/efficiencyMonitor.js` | Inactive ETH detection (available - staged) | +| `src/monitors/withdrawalMonitor.js` | Withdrawal queue helpers | +| `src/metrics/definitions.js` | All Prometheus Gauge/Counter definitions (`vault` metrics use `vault`, `vault_name`, `chain`; watcher/contract-info metrics use their own label sets) | +| `src/metrics/server.js` | HTTP server on `/metrics` (native `http` module) | +| `src/notifications/discord.js` | Discord embeds with per-alert-type cooldown | +| `src/grafana/panels.ts` | Reusable panel builder helpers (statPanel, timeseriesPanel) | +| `src/grafana/dashboard.ts` | Dashboard definition (config-driven rows + panels) | +| `src/grafana/build.ts` | Script that compiles dashboard to `grafana/dashboard.json` | + +## Grafana dashboard + +Dashboard is defined as code in `src/grafana/` using the [Grafana Foundation SDK](https://grafana.com/docs/grafana/latest/observability-as-code/foundation-sdk) (TypeScript). Run `npm run grafana:build` at repo root to compile to `grafana/dashboard.json`. On import, Grafana prompts for **Prometheus** and **Loki** via `__inputs` (`DS_PROMETHEUS`, `DS_LOKI`) injected by `src/grafana/build.ts`. Panel datasources reference `${DS_PROMETHEUS}` / `${DS_LOKI}` (`DATASOURCE_VAR` in `src/grafana/panels.ts`, `LOKI_DS_VAR` in `src/grafana/dashboard.ts`). Template variables at runtime: **Chain**, **Vault**, etc. — not datasource pickers in the row. + +## Contracts read + +- **VaultHub** (global): `isVaultHealthy`, `healthShortfallShares`, `liabilityShares`, `totalMintingCapacityShares`, `isReportFresh`, `withdrawableValue`, `totalValue` +- **stETH** (global): `getPooledEthByShares` (converts liability shares AND minting capacity shares to ETH) +- **StakingVault** (per vault): `availableBalance`, `stagedBalance`, `nodeOperator` +- **WithdrawalQueue** (auto-discovered from pool): `unfinalizedRequestsNumber`, `unfinalizedAssets`, `getLastRequestId`, `getLastFinalizedRequestId` +- **Pool** (per vault): `WITHDRAWAL_QUEUE()`, `DASHBOARD()` (called once at startup for auto-discovery) +- **Dashboard** (per vault): `accruedFee`, `pdgPolicy` +- **PredepositGuarantee** (global): `nodeOperatorBalance`, `unlockedBalance`, `pendingActivations` + +## Metrics + +All values in ETH (not wei) unless the metric name indicates shares, IDs, or counters. Most gauges use labels `vault`, `vault_name`, `chain` (mainnet/hoodi). **Full list:** root `README.md` § Metrics. + +Highlighted PDG-related gauges: +- `lido_vault_node_operator_fee_eth`: Undisbursed node operator fee in ETH from `Dashboard.accruedFee()`. +- `lido_vault_pdg_total_eth`: Total PDG guarantee balance in ETH. +- `lido_vault_pdg_locked_eth`: PDG guarantee currently locked by active predeposits. +- `lido_vault_pdg_unlocked_eth`: PDG guarantee available for new predeposits. +- `lido_vault_pdg_pending_activations`: Validators pending activation in PDG flow. +- `lido_vault_pdg_policy`: Dashboard PDG policy enum (`0=STRICT`, `1=ALLOW_PROVE`, `2=ALLOW_DEPOSIT_AND_PROVE`). + +## Alerts (Discord) + +Only when `DISCORD_WEBHOOK_URL` is set. No recovery messages. Cooldown per alert type per vault (`ALERT_COOLDOWN_MIN`, default 30 min). Severities: warning (yellow), critical (red). Implemented in `src/index.js` + `src/notifications/discord.js`. + +- **Inactive ETH**: inactive buffer above `INACTIVE_ETH_THRESHOLD`. +- **Withdrawal Requests Pending**: `unfinalizedRequests > 0` (shows pending ETH, vault balance, deficit context where applicable). +- **Health — warning**: health factor below `HEALTH_WARNING_THRESHOLD` (and not in critical branch). +- **Health — critical**: health factor below `HEALTH_CRITICAL_THRESHOLD`. +- **Forced rebalance**: `healthShortfallShares > 0` and not `MAX_UINT256` sentinel. +- **Utilization high**: utilization at or above `UTILIZATION_WARNING_THRESHOLD`. +- **Vault unhealthy**: `!isHealthy` from VaultHub. + +## Metrics – special + +- `lido_vault_contracts_info` (labels: `vault_addr`, `pool_addr`, `wq_addr`, `dashboard_addr`, `vault_name`, `chain`): Set once at startup. Carries contract addresses for dashboard display. + +## Config + +Required env vars: `RPC_URL`, `CHAIN`, `VAULT_CONFIGS`. See `.env.example`. + +`CHAIN` accepts `mainnet` or `hoodi`. Chain ID and all Lido contract addresses (VaultHub, stETH, PDG) are resolved automatically from `src/networks.json` — no need to set them in the env. + +`VAULT_CONFIGS` is a JSON array. Each entry requires `vault` and `vault_name`. `pool` is optional. Vaults without a DeFi Wrapper pool skip WQ/dashboard **auto**-discovery; all VaultHub and StakingVault metrics still work. `withdrawalQueue` and `dashboard` can be set manually even without `pool`. + +Optional env (not in `.env.example`): e.g. `UTILIZATION_WARNING_THRESHOLD` (default `95`) — see `src/config.js` and root `README.md` § Configuration. + +## Network addresses + +Lido protocol addresses are defined in `src/networks.json` and loaded automatically based on `CHAIN`. + +### Hoodi + +- VaultHub: `0x4C9fFC325392090F789255b9948Ab1659b797964` +- stETH: `0x3508A952176b3c15387C97BE809eaffB1982176a` +- PDG: `0xa5F55f3402beA2B14AE15Dae1b6811457D43581d` +- Vault (Stakely): `0xC821709e31a9Dc9de2d56e332b031ee841CAa420` +- Pool: `0xC6B9509E24F9aE488eDd3dCD488559f85678C552` +- Strategy: `0xbF4797De82787A60101e5e45CEc7f29b27d83D98` + +### Mainnet + +- VaultHub: `0x1d201BE093d847f6446530Efb0E8Fb426d176709` +- stETH: `0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84` +- PDG: `0xF4bF42c6D6A0E38825785048124DBAD6c9eaaac3` + +## ABI notes + +- Uses viem `parseAbi` with human-readable strings. If Lido upgrades a contract and changes a method signature, update the corresponding string in `src/abis.js`. +- `vaultConnection()` struct is now read via raw ABI tuple in `vaultConnectionAbi` (not `parseAbi`). The `forcedRebalanceThresholdBP` is read from contract at each poll; env `FORCED_REBALANCE_THRESHOLD_BP` is only a fallback if the read fails. +- WithdrawalQueue methods are `getLastRequestId` / `getLastFinalizedRequestId` (not `lastRequestId`). + +## Versioning + +Uses [Semantic Versioning](https://semver.org/) in `package.json`. The version is read at startup and exposed via `stvaults_watcher_info{version="..."}`. + +- **Patch** (`x.y.Z`): bug fixes, dependency bumps, config tweaks. +- **Minor** (`x.Y.0`): new metrics, alerts, dashboard sections, env var changes, non-breaking features. +- **Major** (`X.0.0`): breaking changes (renamed env vars, removed metrics, incompatible config format). + +Bump the version in `package.json` when shipping meaningful changes. + +## Living documentation + +This documentation MUST be kept up to date. When you make changes to the project: + +- **New file or module**: Add it to the "Key files" table above. +- **New contract method**: Add it to "Contracts read". +- **New metric or label**: Update "Metrics" / PDG highlights here and the full catalog in root `README.md` § Metrics. +- **New alert type**: Update "Alerts" section. +- **New env var**: Update "Config" section. +- **New network/addresses**: Add to "Hoodi addresses" or create a new section. +- **ABI gotchas**: Document in "ABI notes". +- **Version bump**: Update version in `package.json` following semver rules above. +- **Coding convention changes**: Update `.cursor/rules/coding-conventions.mdc`. + +Rules location: `.cursor/rules/project-overview.mdc` (this file) and `.cursor/rules/coding-conventions.mdc`. diff --git a/.cursor/rules/testing-conventions.mdc b/.cursor/rules/testing-conventions.mdc new file mode 100644 index 0000000..a9cb894 --- /dev/null +++ b/.cursor/rules/testing-conventions.mdc @@ -0,0 +1,25 @@ +--- +description: Testing conventions for stvaults-watcher +globs: tests/**/*.* +alwaysApply: false +--- + +# Testing conventions + +- **Runner**: Use Node's built-in test runner (`node:test`). +- **Folder layout**: Keep all tests inside `tests/`, mirroring `src/` by domain (`tests/monitors`, `tests/metrics`, `tests/notifications`, `tests/grafana`). +- **File naming**: Use `*.test.js` for JavaScript modules and `*.test.ts` only for Grafana TypeScript builders. +- **No extra frameworks**: Do not add Jest, Mocha, Vitest, Sinon, or other test dependencies. +- **Mocking strategy**: + - Use plain mocks/stubs for `client.readContract` in monitor tests. + - Override `process.env` in config tests and restore it after tests. + - Mock `globalThis.fetch` and `Date.now` for Discord tests. +- **Assertions**: Use `node:assert/strict` for deterministic assertions. +- **Coverage focus**: Prioritize exported pure functions first, then integration-style tests for server/metrics and contract polling orchestration. +- **Grafana tests**: + - Run TypeScript tests with `tsx --test`. + - Validate both builder output (`buildDashboard`, panel helpers) and generated `grafana/dashboard.json` patch expectations. +- **CI requirements**: + - Run `npm run test:all`. + - Build dashboard (`npm run grafana:build`) and ensure `grafana/dashboard.json` is in sync. + - Build Docker image to validate `Dockerfile`. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d750706 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.env +.git +*.log +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9fe8edf --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Required environment variables only. +# For optional overrides and default values, see README.md +RPC_URL=https://ethereum-json-rpc.stakely.io +CHAIN=mainnet +# Vaults (JSON array) +# Required fields: vault, vault_name +# Optional fields: pool (DeFi Wrapper), withdrawalQueue, dashboard +# Examples: +# With pool: {"vault":"0x...","pool":"0x...","vault_name":"my-vault"} +# Without pool: {"vault":"0x...","vault_name":"my-vault"} +# Multi-vault: [{"vault":"0x...","pool":"0x...","vault_name":"vault-a"},{"vault":"0x...","vault_name":"vault-b"}] +VAULT_CONFIGS=[{"vault":"0x0000","pool":"0x000","vault_name":"vault-name"}] diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..f343235 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +If you discover any security related issues, please contact with admin@stakely.io \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f064aeb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +# Validates Node app + Grafana dashboard as code. +# Note: Grafana's official dashboard-linter (github.com/grafana/dashboard-linter) enforces +# kubernetes-mixin-style rules (job/instance matchers, datasource variable names, editable:false, etc.) +# and does not match this project's PromQL; use JSON + build sync checks instead. + +name: CI + +on: + push: + branches: [develop] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + + - run: npm ci + + - name: Run tests + run: npm run test:all + + - name: Build Docker image + run: docker build -t stvaults-watcher:ci . + + - name: Build Grafana dashboard from TypeScript + run: npm run grafana:build + + - name: Ensure committed dashboard matches generated output + run: | + if ! git diff --exit-code -- grafana/dashboard.json; then + echo "::error::grafana/dashboard.json is out of date. Run npm run grafana:build and commit the result." + exit 1 + fi + + - name: Validate dashboard JSON structure + run: | + node <<'NODE' + const fs = require("fs"); + const raw = fs.readFileSync("grafana/dashboard.json", "utf8"); + let d; + try { + d = JSON.parse(raw); + } catch (e) { + console.error("Invalid JSON:", e.message); + process.exit(1); + } + if (typeof d.schemaVersion !== "number") { + console.error("Expected dashboard.schemaVersion to be a number"); + process.exit(1); + } + if (!Array.isArray(d.panels)) { + console.error("Expected dashboard.panels to be an array"); + process.exit(1); + } + console.log("dashboard.json: valid JSON and minimal Grafana shape OK"); + NODE diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..d7ab8eb --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,28 @@ +name: Docker Publish + +on: + push: + tags: + - 'v*.*.*' + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64 + push: true + tags: | + stakely/stvaults-watcher:${{ github.ref_name }} + stakely/stvaults-watcher:latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..c587898 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,46 @@ +name: Lint (auto-fix on develop) + +on: + push: + branches: [develop] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + lint-and-fix: + runs-on: ubuntu-latest + if: github.actor != 'github-actions[bot]' + steps: + - uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + auto-fix + run: npm run lint:fix + + - name: Commit and push if changed + run: | + if git diff --quiet; then + echo "No lint changes to commit." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "chore: lint (auto-fix)" + git push + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..daf1dd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8cef41 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:24-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev + +COPY src ./src + +RUN chown -R node:node /app + +ENV NODE_ENV=production + +USER node +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f457e4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Stakely + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 207b61f..435ed26 100644 --- a/README.md +++ b/README.md @@ -1 +1,210 @@ # stvaults-watcher + +[![CI](https://github.com/stakely/stvaults-watcher/actions/workflows/ci.yml/badge.svg)](https://github.com/stakely/stvaults-watcher/actions/workflows/ci.yml) +[![Node](https://img.shields.io/badge/node-%3E%3D24-brightgreen)](https://nodejs.org) +[![Version](https://img.shields.io/github/package-json/v/stakely/stvaults-watcher)](package.json) +[![Docker Hub](https://img.shields.io/docker/pulls/stakely/stvaults-watcher?logo=docker&label=Docker%20Hub)](https://hub.docker.com/r/stakely/stvaults-watcher) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +Monitoring watcher for [Lido V3](https://docs.lido.fi) stVaults with DeFi Wrapper pools. Reads on-chain data via [viem](https://viem.sh), exposes Prometheus metrics, and sends Discord alerts for health and withdrawal events. + +

+ Grafana Dashboard +

+ +## Features + +- **Multi-vault**: monitor multiple stVaults from a single instance, each with a human-readable `vault_name` +- **Prometheus metrics**: health factor, utilization ratio, inactive ETH, withdrawal queue, and more +- **Discord alerts**: health warnings/criticals and pending withdrawal requests, with per-vault cooldowns +- **Ethereum mainnet and Hoodi testnet** support + +## Requirements + +- Node.js ≥ 24 **or** Docker +- An Ethereum/Hoodi JSON-RPC endpoint (`RPC_URL`) +- Discord webhook URL *(optional; only needed for alerts)* + +## Quick start + +The image is published to [Docker Hub](https://hub.docker.com/r/stakely/stvaults-watcher) and updated automatically on every release. + +**Docker Compose (recommended)** + +```bash +cp .env.example .env +# Edit .env with your RPC URL and vault addresses +docker compose -f example.docker-compose.yaml up -d +``` + +**Node.js** + +```bash +npm ci +cp .env.example .env +# Edit .env with your RPC URL and vault addresses +node src/index.js +``` + +Metrics will be available at `http://localhost:9600/metrics`. + +

+ stvaults-watcher terminal output +

+ +## Configuration + +Copy `.env.example` to `.env` and adjust the values. + +| Variable | Required | Default | Description | +|---|---|---|---| +| `RPC_URL` | ✓ | | Ethereum/Hoodi JSON-RPC endpoint | +| `CHAIN` | ✓ | | `mainnet` · `hoodi` | +| `VAULT_CONFIGS` | ✓ | | JSON array of vault objects; see below | +| `POLL_INTERVAL_MIN` | | `1` | Polling interval in minutes | +| `METRICS_PORT` | | `9600` | Prometheus metrics port | +| `DISCORD_WEBHOOK_URL` | | | Enables Discord alerts when set | +| `DISCORD_USERNAME` | | `stvaults-watcher` | Webhook display name for Discord alerts | +| `DISCORD_AVATAR_URL` | | `static/avatar.png` | Webhook avatar URL for Discord alerts | +| `ALERT_COOLDOWN_MIN` | | `30` | Alert cooldown per vault per type (minutes) | +| `HEALTH_WARNING_THRESHOLD` | | `107` | Health factor warning level (%) | +| `HEALTH_CRITICAL_THRESHOLD` | | `102` | Health factor critical level (%) | +| `UTILIZATION_WARNING_THRESHOLD` | | `95` | Utilization ratio warning level (%) | +| `INACTIVE_ETH_THRESHOLD` | | `2` | Inactive ETH alert threshold (ETH amount; integer or decimal, e.g. `2` or `2.5`) | + +`CHAIN` is used to resolve `chainId` and core Lido addresses (VaultHub, stETH, PDG) automatically from `src/networks.json`. + +**`VAULT_CONFIGS` format** + +```json +[ + { "vault": "0x...", "pool": "0x...", "vault_name": "my-vault" }, + { "vault": "0x...", "vault_name": "vault-no-pool" }, + { "vault": "0x...", "withdrawalQueue": "0x...", "dashboard": "0x...", "vault_name": "manual-wrapper-addrs" } +] +``` + +`pool` is optional. Vaults without a DeFi Wrapper pool skip WithdrawalQueue/dashboard auto-discovery; all VaultHub and StakingVault metrics still work. You can set `withdrawalQueue` and `dashboard` manually when there is no `pool`. + +## Alerts (Discord) + +When `DISCORD_WEBHOOK_URL` is set, the watcher sends embeds with a **per-vault, per-alert-type** cooldown (`ALERT_COOLDOWN_MIN`). There are no “recovery” notifications when a condition clears. + +| Alert (type) | When it fires | +|---|---| +| Inactive ETH | Buffer inefficiency above `INACTIVE_ETH_THRESHOLD` | +| Withdrawal requests pending | `unfinalizedRequests > 0` | +| Health — warning | Health factor below `HEALTH_WARNING_THRESHOLD` (and not already critical) | +| Health — critical | Health factor below `HEALTH_CRITICAL_THRESHOLD` | +| Forced rebalance | `healthShortfallShares > 0` and not the sentinel max value | +| Utilization high | Utilization at or above `UTILIZATION_WARNING_THRESHOLD` | +| Vault unhealthy | VaultHub reports not healthy (`!isHealthy`) | + +## Metrics + +All ETH values are in ETH (not wei), except metrics explicitly named as shares/ids/counters. + +### Vault - General + +| Metric | Description | +|---|---| +| `lido_vault_total_value_eth` | Total vault value in ETH | +| `lido_vault_available_balance_eth` | ETH available in vault buffer | +| `lido_vault_staged_balance_eth` | ETH staged for validators | +| `lido_vault_inactive_eth` | Inactive ETH (available − staged) | +| `lido_vault_withdrawable_value_eth` | ETH withdrawable from VaultHub | +| `lido_vault_node_operator_fee_eth` | Undisbursed node operator fee in ETH | + +### Vault - Health & Ratios + +| Metric | Description | +|---|---| +| `lido_vault_health_factor` | Health factor (%) | +| `lido_vault_is_healthy` | `1` if vault is healthy, `0` otherwise | +| `lido_vault_health_shortfall_shares` | Shares needed to restore health | +| `lido_vault_utilization_ratio` | Utilization ratio (%) | +| `lido_vault_report_fresh` | `1` if oracle report is fresh, `0` if stale | +| `lido_vault_forced_rebalance_threshold` | Forced rebalance threshold (%) | +| `lido_vault_reserve_ratio` | Reserve ratio (%) | + +### Vault - stETH + +| Metric | Description | +|---|---| +| `lido_vault_steth_liability_shares` | stETH liability in shares | +| `lido_vault_steth_liability_eth` | stETH liability converted to ETH | +| `lido_vault_minting_capacity_shares` | Total minting capacity in shares | +| `lido_vault_minting_capacity_eth` | Total minting capacity converted to ETH | + +### Vault - PDG + +| Metric | Description | +|---|---| +| `lido_vault_pdg_total_eth` | Total PDG guarantee balance in ETH | +| `lido_vault_pdg_locked_eth` | Locked PDG guarantee balance in ETH | +| `lido_vault_pdg_unlocked_eth` | Unlocked PDG guarantee balance in ETH | +| `lido_vault_pdg_pending_activations` | PDG validators pending activation | +| `lido_vault_pdg_policy` | PDG policy enum (`0=STRICT`, `1=ALLOW_PROVE`, `2=ALLOW_DEPOSIT_AND_PROVE`) | + +### Withdrawal Queue + +| Metric | Description | +|---|---| +| `lido_wq_unfinalized_requests` | Pending withdrawal requests | +| `lido_wq_unfinalized_assets_eth` | ETH pending in the withdrawal queue | +| `lido_wq_last_request_id` | Last withdrawal request ID | +| `lido_wq_last_finalized_id` | Last finalized withdrawal request ID | + +### Watcher & Metadata + +| Metric | Description | +|---|---| +| `lido_vault_contracts_info` | Contract addresses info (set once at startup) | +| `stvaults_watcher_info` | Watcher metadata (`version`, `chain`, `explorer_url`) | +| `stvaults_watcher_last_poll_timestamp` | Unix timestamp of last successful poll | +| `stvaults_watcher_poll_errors_total` | Total polling errors counter | + +Most vault metrics include labels `vault`, `vault_name`, and `chain`. +`lido_vault_contracts_info` and `stvaults_watcher_*` use dedicated label sets. + +## Grafana dashboard + +The dashboard is defined as TypeScript and compiled to `grafana/dashboard.json`. + +```bash +npm run grafana:build # regenerate grafana/dashboard.json +``` + +Import `grafana/dashboard.json` in Grafana: the import wizard asks for **Prometheus** (`DS_PROMETHEUS`) and **Loki** (`DS_LOKI`) — see `src/grafana/build.ts` (`__inputs`). After import, use the **Chain** and **Vault** template variables at the top to filter metrics; log panels use the Loki datasource you picked. + +## Logging (Loki) + +If you're running Grafana Loki, you can ship container logs directly via the Docker Loki logging driver. Add a `logging` block to your service in `docker-compose.yaml`: + +```yaml +services: + stvaults-watcher: + # ... rest of your service definition ... + logging: + driver: loki + options: + loki-url: "https://user:password@lokiserver.com/loki/api/v1/push" + loki-external-labels: "container_name={{.Name}},app=stvaults-watcher,chain=mainnet,instance=stvaults-watcher-mainnet" +``` + +> The driver must be installed first: `docker plugin install grafana/loki-docker-driver:latest --alias loki --grant-all-permissions` + +All log lines are emitted to **stdout** (the watcher never uses `console.error`/`console.warn`), so Docker labels every line as `stream=stdout`. Log level (`INFO`, `WARN`, `ERROR`) is embedded in the message and can be parsed by Loki pipelines. + +## Development + +```bash +npm ci +npm test +``` + +CI runs on every PR to `main`: unit tests, Grafana dashboard build, and JSON structure validation. + +## License + +[MIT](LICENSE) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..8b2c077 --- /dev/null +++ b/TODO.md @@ -0,0 +1,25 @@ +# TODO / Known workarounds + +## Grafana Foundation SDK does not support `__inputs` + +**File:** `src/grafana/build.ts` + +**Why post-processing is needed:** The Grafana Foundation SDK has no API to declare +`__inputs` or `__requires`. These top-level fields are what Grafana uses to prompt +the user for Prometheus and Loki datasource selection during import. Without them, +Grafana skips the prompt and renders datasource variables as visible dropdowns. + +**Current approach:** `build.ts` post-processes the SDK output to: +1. Remove `type:"datasource"` entries from `templating.list` (those are the visible dropdowns). +2. Inject `__inputs` (with `${DS_PROMETHEUS}` / `${DS_LOKI}` placeholders) and `__requires`. + +All datasource `uid` refs use `${DS_PROMETHEUS}` / `${DS_LOKI}` because `DATASOURCE_VAR` +in `panels.ts` and `LOKI_DS_VAR` in `dashboard.ts` are set to those names directly. +No string-replacement post-processing is needed. + +**What to do if the SDK adds `__inputs` support:** +1. Move `__inputs` / `__requires` declaration into `dashboard.ts` using the SDK API. +2. Remove `removeSdkDatasourceVars` and `addGrafanaInputs` from `build.ts`. +3. Run `npm run grafana:build` and verify the import prompt works without post-processing. + +**Tracking:** https://github.com/grafana/grafana-foundation-sdk/issues diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..53d055b --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,49 @@ +import tsParser from "@typescript-eslint/parser"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; + +export default [ + { + ignores: [ + "node_modules/**", + "grafana/dashboard.json", + ".github/**", + ".cursor/**", + "package-lock.json", + "Dockerfile", + "example.docker-compose.yaml", + ], + }, + { + files: ["**/*.{js,ts}"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + parser: tsParser, + globals: { + fetch: "readonly", + process: "readonly", + console: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + }, + }, + plugins: { + "@typescript-eslint": tsPlugin, + }, + rules: { + "no-console": "off", + semi: ["error", "always"], + eqeqeq: ["error", "always"], + // Permite `if (x) return y;` sin llaves (lo que ya usa el código actual). + curly: ["error", "multi-line"], + "@typescript-eslint/no-unused-vars": [ + "error", + { args: "none", ignoreRestSiblings: true, varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, + ], + "@typescript-eslint/no-explicit-any": "off", + }, + }, +]; + diff --git a/example.docker-compose.yaml b/example.docker-compose.yaml new file mode 100644 index 0000000..60c043a --- /dev/null +++ b/example.docker-compose.yaml @@ -0,0 +1,7 @@ +services: + stvaults-watcher: + build: . + ports: + - "127.0.0.1:9600:9600" + env_file: .env + restart: unless-stopped diff --git a/grafana/dashboard.json b/grafana/dashboard.json new file mode 100644 index 0000000..9c5a557 --- /dev/null +++ b/grafana/dashboard.json @@ -0,0 +1,2722 @@ +{ + "timezone": "browser", + "editable": true, + "graphTooltip": 0, + "fiscalYearStartMonth": 0, + "schemaVersion": 42, + "templating": { + "list": [ + { + "type": "query", + "name": "chain", + "skipUrlSync": false, + "multi": false, + "allowCustomValue": true, + "includeAll": false, + "auto": false, + "auto_min": "10s", + "auto_count": 30, + "label": "Chain", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "query": "label_values(lido_vault_total_value_eth, chain)", + "refresh": 1, + "sort": 1 + }, + { + "type": "query", + "name": "vault_name", + "skipUrlSync": false, + "multi": false, + "allowCustomValue": false, + "includeAll": true, + "auto": false, + "auto_min": "10s", + "auto_count": 30, + "label": "Vault", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "query": "label_values(lido_vault_total_value_eth{chain=~\"$chain\"}, vault_name)", + "refresh": 2, + "sort": 1, + "allValue": ".*" + }, + { + "type": "query", + "name": "explorer_base", + "label": "Explorer", + "hide": 2, + "skipUrlSync": false, + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "definition": "label_values(stvaults_watcher_info{chain=\"$chain\"}, explorer_url)", + "query": { + "query": "label_values(stvaults_watcher_info{chain=\"$chain\"}, explorer_url)", + "refId": "StandardVariableQuery" + }, + "refresh": 2, + "sort": 0, + "includeAll": false, + "multi": false, + "allValue": null, + "current": {}, + "options": [] + } + ] + }, + "annotations": {}, + "title": "stVaults Watcher", + "uid": "stvaults-watcher", + "tags": [ + "ETHEREUM", + "LIDO" + ], + "refresh": "1m", + "time": { + "from": "now-6h", + "to": "now" + }, + "panels": [ + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "📊 Status", + "gridPos": { + "x": 0, + "y": 0, + "h": 1, + "w": 24 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Watcher version", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "name", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "stvaults_watcher_info{chain=\"$chain\"}", + "legendFormat": "v{{version}}" + } + ], + "gridPos": { + "x": 0, + "y": 1, + "w": 4, + "h": 5 + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#B877D9" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Watcher alive", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "(time() - stvaults_watcher_last_poll_timestamp{chain=~\"$chain\"}) < bool 300", + "legendFormat": "{{chain}}" + } + ], + "description": "YES if the watcher has polled in the last 5 minutes. Calculated as: (current time − last poll timestamp) < 300 seconds.", + "gridPos": { + "x": 4, + "y": 1, + "w": 4, + "h": 5 + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "NO", + "color": "#F2495C", + "index": 0 + }, + "1": { + "text": "YES", + "color": "#73BF69", + "index": 1 + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#F2495C" + }, + { + "value": 1, + "color": "#73BF69" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Vault healthy", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_is_healthy{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "description": "Whether the vault is considered healthy by VaultHub (isVaultHealthy on-chain).", + "gridPos": { + "x": 8, + "y": 1, + "w": 4, + "h": 5 + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "NO", + "color": "#F2495C", + "index": 0 + }, + "1": { + "text": "YES", + "color": "#73BF69", + "index": 1 + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#F2495C" + }, + { + "value": 1, + "color": "#73BF69" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Unfinalized requests", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_wq_unfinalized_requests{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "description": "Number of withdrawal requests in the queue not yet finalized on L1.", + "fieldConfig": { + "defaults": { + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 1, + "color": "#FF9830" + }, + { + "value": 10, + "color": "#F2495C" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 12, + "y": 1, + "w": 4, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Inactive ETH", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_inactive_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 1, + "color": "#FF9830" + }, + { + "value": 32, + "color": "#F2495C" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "ETH in the vault that is not yet staked (available minus staged). Used for withdrawals and rebalancing.", + "gridPos": { + "x": 16, + "y": 1, + "w": 4, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Withdrawal deficit", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "clamp_min(lido_wq_unfinalized_assets_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"} - lido_vault_available_balance_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}, 0)", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 0.01, + "color": "#FF9830" + }, + { + "value": 1, + "color": "#F2495C" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "ETH needed from validators to cover pending withdrawals (0 = enough liquidity)", + "gridPos": { + "x": 20, + "y": 1, + "w": 4, + "h": 5 + } + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "💰 Vault state", + "gridPos": { + "x": 0, + "y": 6, + "h": 1, + "w": 24 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Total value", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "area", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_total_value_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "Total value of the vault (VaultHub totalValue) in ETH.", + "gridPos": { + "x": 0, + "y": 7, + "w": 4, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Available balance", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "area", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_available_balance_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "ETH available in the StakingVault (liquid, not yet staged for staking).", + "gridPos": { + "x": 4, + "y": 7, + "w": 4, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Staged balance", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "area", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_staged_balance_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "ETH staged in the StakingVault (pending to be staked).", + "gridPos": { + "x": 8, + "y": 7, + "w": 4, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Withdrawable value", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "area", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_withdrawable_value_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "ETH that can be withdrawn (VaultHub withdrawableValue).", + "gridPos": { + "x": 12, + "y": 7, + "w": 4, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Node Operator fee", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "area", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_node_operator_fee_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "Undisbursed node operator fee accrued in Dashboard (accruedFee).", + "gridPos": { + "x": 16, + "y": 7, + "w": 4, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Staking efficiency", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "(lido_vault_total_value_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"} - lido_vault_inactive_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}) / lido_vault_total_value_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"} * 100", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#F2495C" + }, + { + "value": 80, + "color": "#FF9830" + }, + { + "value": 96, + "color": "#73BF69" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "% of vault ETH actively staked and generating yield. Computed as (total_value − inactive_eth) / total_value × 100.", + "gridPos": { + "x": 20, + "y": 7, + "w": 4, + "h": 5 + } + }, + { + "type": "timeseries", + "transparent": false, + "repeatDirection": "h", + "title": "Total value (ETH)", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 15 + }, + "unit": "eth", + "decimals": 4 + }, + "overrides": [] + }, + "targets": [ + { + "expr": "lido_vault_total_value_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "gridPos": { + "x": 0, + "y": 12, + "w": 12, + "h": 8 + } + }, + { + "type": "timeseries", + "transparent": false, + "repeatDirection": "h", + "title": "Available vs Staged (ETH)", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 15 + }, + "unit": "eth", + "decimals": 4 + }, + "overrides": [] + }, + "targets": [ + { + "expr": "lido_vault_available_balance_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "gridPos": { + "x": 12, + "y": 12, + "w": 12, + "h": 8 + } + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "📋 Contracts", + "gridPos": { + "x": 0, + "y": 20, + "h": 1, + "w": 24 + } + }, + { + "type": "table", + "title": "Contracts", + "description": "Contract addresses. Click an address to open in the block explorer.", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "x": 0, + "y": 21, + "w": 24, + "h": 6 + }, + "targets": [ + { + "expr": "lido_vault_contracts_info{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}", + "refId": "A", + "instant": true + } + ], + "transformations": [ + { + "id": "labelsToFields", + "options": { + "mode": "rows", + "keepLabels": [ + "vault_addr", + "pool_addr", + "wq_addr", + "dashboard_addr" + ] + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "renameByName": { + "label": "Name", + "value": "Contract" + } + } + }, + { + "id": "filterByValue", + "options": { + "type": "include", + "match": "any", + "filters": [ + { + "fieldName": "Contract", + "config": { + "id": "isNotNull" + } + } + ] + } + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Contract" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open in Explorer", + "url": "${explorer_base}/address/${__value.raw}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Name" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "vault_addr": { + "text": "Vault", + "color": "green", + "index": 0 + } + } + }, + { + "type": "value", + "options": { + "pool_addr": { + "text": "Pool", + "color": "blue", + "index": 1 + } + } + }, + { + "type": "value", + "options": { + "wq_addr": { + "text": "Withdrawal Queue", + "color": "orange", + "index": 2 + } + } + }, + { + "type": "value", + "options": { + "dashboard_addr": { + "text": "Dashboard", + "color": "purple", + "index": 3 + } + } + } + ] + } + ] + } + ] + }, + "options": { + "showHeader": true, + "cellHeight": "sm", + "footer": { + "show": false, + "reducer": [ + "sum" + ], + "countRows": false, + "fields": "" + } + } + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "🏥 Health", + "gridPos": { + "x": 0, + "y": 27, + "h": 1, + "w": 24 + } + }, + { + "type": "gauge", + "transparent": false, + "repeatDirection": "h", + "title": "Health factor", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto", + "minVizWidth": 75, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "minVizHeight": 75, + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_health_factor{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 2, + "min": 0, + "max": 200, + "mappings": [ + { + "type": "range", + "options": { + "from": 9999, + "to": null, + "result": { + "text": "∞", + "color": "#73BF69" + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#F2495C" + }, + { + "value": 105, + "color": "#FF9830" + }, + { + "value": 120, + "color": "#73BF69" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 0, + "y": 28, + "w": 6, + "h": 7 + } + }, + { + "type": "gauge", + "transparent": false, + "repeatDirection": "h", + "title": "stETH Minted", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto", + "minVizWidth": 75, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "minVizHeight": 75, + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_utilization_ratio{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 2, + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 80, + "color": "#FF9830" + }, + { + "value": 95, + "color": "#F2495C" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "% of stETH minting capacity used (liabilityShares / mintingCapacityShares)", + "gridPos": { + "x": 6, + "y": 28, + "w": 6, + "h": 7 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Forced rebalance threshold", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "none", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_forced_rebalance_threshold{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 2 + }, + "overrides": [] + }, + "description": "If Health Factor falls below 100% (based on this threshold), the vault is subject to forced rebalancing", + "gridPos": { + "x": 12, + "y": 28, + "w": 3, + "h": 7 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Reserve ratio", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "none", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_reserve_ratio{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 2 + }, + "overrides": [] + }, + "description": "% of Total Value reserved as collateral; stETH cannot be minted against this amount", + "gridPos": { + "x": 15, + "y": 28, + "w": 3, + "h": 7 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Oracle report", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_report_fresh{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "gridPos": { + "x": 18, + "y": 28, + "w": 3, + "h": 7 + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "STALE", + "color": "#5794F2", + "index": 0 + }, + "1": { + "text": "FRESH", + "color": "#73BF69", + "index": 1 + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + }, + { + "value": 1, + "color": "#73BF69" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Health shortfall", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_health_shortfall_shares{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "description": "Shares needed to restore health (0 = healthy)", + "fieldConfig": { + "defaults": { + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 1, + "color": "#F2495C" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 21, + "y": 28, + "w": 3, + "h": 7 + } + }, + { + "type": "timeseries", + "transparent": false, + "repeatDirection": "h", + "title": "Health factor %", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 15 + }, + "unit": "percent", + "decimals": 2, + "mappings": [ + { + "type": "range", + "options": { + "from": 9999, + "to": null, + "result": { + "text": "∞", + "color": "#73BF69" + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#F2495C" + }, + { + "value": 105, + "color": "#FF9830" + }, + { + "value": 120, + "color": "#73BF69" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "clamp_max(lido_vault_health_factor{chain=~\"$chain\",vault_name=~\"$vault_name\"}, 9999)", + "legendFormat": "{{vault_name}}" + } + ], + "gridPos": { + "x": 0, + "y": 35, + "w": 12, + "h": 8 + } + }, + { + "type": "timeseries", + "transparent": false, + "repeatDirection": "h", + "title": "stETH Minted %", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 15 + }, + "unit": "percent", + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 80, + "color": "#FF9830" + }, + { + "value": 95, + "color": "#F2495C" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "targets": [ + { + "expr": "lido_vault_utilization_ratio{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "gridPos": { + "x": 12, + "y": 35, + "w": 12, + "h": 8 + } + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "🪙 stETH liability", + "gridPos": { + "x": 0, + "y": 43, + "h": 1, + "w": 24 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "stETH liability", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "area", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_steth_liability_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 0, + "y": 44, + "w": 8, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Minting capacity", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "area", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_minting_capacity_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 8, + "y": 44, + "w": 8, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Withdrawable value", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "area", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_withdrawable_value_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 16, + "y": 44, + "w": 8, + "h": 5 + } + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "📤 Withdrawal queue", + "gridPos": { + "x": 0, + "y": 49, + "h": 1, + "w": 24 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Unfinalized requests", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_wq_unfinalized_requests{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 1, + "color": "#FF9830" + }, + { + "value": 10, + "color": "#F2495C" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 0, + "y": 50, + "w": 6, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Unfinalized assets", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "area", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_wq_unfinalized_assets_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 6, + "y": 50, + "w": 6, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Last request ID", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "none", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_wq_last_request_id{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "decimals": 0 + }, + "overrides": [] + }, + "gridPos": { + "x": 12, + "y": 50, + "w": 6, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Last finalized ID", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "none", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_wq_last_finalized_id{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "decimals": 0 + }, + "overrides": [] + }, + "gridPos": { + "x": 18, + "y": 50, + "w": 6, + "h": 5 + } + }, + { + "type": "timeseries", + "transparent": false, + "repeatDirection": "h", + "title": "Unfinalized assets (ETH)", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 15 + }, + "unit": "eth", + "decimals": 4 + }, + "overrides": [] + }, + "targets": [ + { + "expr": "lido_wq_unfinalized_assets_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "gridPos": { + "x": 0, + "y": 55, + "w": 24, + "h": 8 + } + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "🛡️ PDG (Predeposit Guarantee)", + "gridPos": { + "x": 0, + "y": 63, + "h": 1, + "w": 24 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "PDG Policy", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_pdg_policy{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "description": "Dashboard policy for PDG flow: 0=STRICT, 1=ALLOW_PROVE, 2=ALLOW_DEPOSIT_AND_PROVE.", + "fieldConfig": { + "defaults": { + "decimals": 0, + "mappings": [ + { + "type": "value", + "options": { + "0": { + "text": "STRICT", + "color": "#5794F2", + "index": 0 + }, + "1": { + "text": "ALLOW_PROVE", + "color": "#FF9830", + "index": 1 + }, + "2": { + "text": "ALLOW_DEPOSIT_AND_PROVE", + "color": "#73BF69", + "index": 2 + } + } + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#5794F2" + }, + { + "value": 1, + "color": "#FF9830" + }, + { + "value": 2, + "color": "#73BF69" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 0, + "y": 64, + "w": 5, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "PDG Total", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_pdg_total_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "Total guarantee balance in PDG for this vault node operator.", + "gridPos": { + "x": 5, + "y": 64, + "w": 5, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "PDG Locked", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_pdg_locked_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "Guarantee currently locked by active predeposits.", + "gridPos": { + "x": 10, + "y": 64, + "w": 5, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "PDG Unlocked", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_pdg_unlocked_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "eth", + "decimals": 4, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 0.000001, + "color": "#FF780A" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "description": "Guarantee available for new predeposits (total - locked).", + "gridPos": { + "x": 15, + "y": 64, + "w": 5, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "PDG Pending activations", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "background_solid", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "lido_vault_pdg_pending_activations{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}}" + } + ], + "description": "Validators in PREDEPOSITED/PROVEN stages awaiting activation.", + "fieldConfig": { + "defaults": { + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 1, + "color": "#FF9830" + }, + { + "value": 10, + "color": "#F2495C" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 20, + "y": 64, + "w": 4, + "h": 5 + } + }, + { + "type": "timeseries", + "transparent": false, + "repeatDirection": "h", + "title": "PDG Locked vs Unlocked (ETH)", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 15 + }, + "unit": "eth", + "decimals": 4 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "locked$" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#FF9830" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "unlocked$" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#73BF69" + } + } + ] + } + ] + }, + "targets": [ + { + "expr": "lido_vault_pdg_locked_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}} locked" + }, + { + "expr": "lido_vault_pdg_unlocked_eth{chain=~\"$chain\",vault_name=~\"$vault_name\"}", + "legendFormat": "{{vault_name}} unlocked" + } + ], + "gridPos": { + "x": 0, + "y": 69, + "w": 24, + "h": 8 + } + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "🤖 Watcher", + "gridPos": { + "x": 0, + "y": 77, + "h": 1, + "w": 24 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Poll errors", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "increase(stvaults_watcher_poll_errors_total{chain=~\"$chain\"}[$__range])", + "legendFormat": "{{chain}}" + } + ], + "fieldConfig": { + "defaults": { + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 1, + "color": "#FF780A" + }, + { + "value": 10, + "color": "#F2495C" + } + ] + }, + "color": { + "mode": "thresholds" + }, + "noValue": "0" + }, + "overrides": [] + }, + "gridPos": { + "x": 0, + "y": 78, + "w": 6, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Last successful poll", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "none", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "stvaults_watcher_last_poll_timestamp{chain=~\"$chain\"} * 1000", + "legendFormat": "{{chain}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "dateTimeFromNow" + }, + "overrides": [] + }, + "gridPos": { + "x": 6, + "y": 78, + "w": 6, + "h": 5 + } + }, + { + "type": "stat", + "transparent": false, + "repeatDirection": "h", + "title": "Time since last poll", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "options": { + "graphMode": "none", + "colorMode": "value", + "justifyMode": "auto", + "textMode": "auto", + "wideLayout": true, + "showPercentChange": false, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "percentChangeColorMode": "standard", + "orientation": "auto" + }, + "targets": [ + { + "expr": "time() - stvaults_watcher_last_poll_timestamp{chain=~\"$chain\"}", + "legendFormat": "{{chain}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "#73BF69" + }, + { + "value": 120, + "color": "#FF9830" + }, + { + "value": 300, + "color": "#F2495C" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "x": 12, + "y": 78, + "w": 6, + "h": 5 + } + }, + { + "type": "row", + "collapsed": false, + "id": 0, + "panels": [], + "title": "📜 Logs", + "gridPos": { + "x": 0, + "y": 83, + "h": 1, + "w": 24 + } + }, + { + "type": "logs", + "transparent": false, + "repeatDirection": "h", + "title": "Watcher logs", + "datasource": { + "type": "loki", + "uid": "${DS_LOKI}" + }, + "options": { + "showLabels": false, + "showCommonLabels": false, + "showTime": true, + "showLogContextToggle": false, + "wrapLogMessage": true, + "prettifyLogMessage": false, + "enableLogDetails": true, + "sortOrder": "Ascending", + "dedupStrategy": "none", + "fontSize": "sm", + "syntaxHighlighting": true + }, + "targets": [ + { + "expr": "{app=\"stvaults-watcher\", chain=~\"$chain\"}", + "maxLines": 200 + } + ], + "gridPos": { + "x": 0, + "y": 84, + "w": 24, + "h": 12 + } + } + ], + "links": [ + { + "title": "GitHub Repository", + "url": "https://github.com/Stakely/stvaults-watcher", + "targetBlank": true, + "icon": "external link" + } + ], + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "current": { + "selected": false, + "value": "" + } + }, + { + "name": "DS_LOKI", + "label": "Loki", + "type": "datasource", + "pluginId": "loki", + "pluginName": "Loki", + "current": { + "selected": false, + "value": "" + } + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "10.x" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "2.x" + }, + { + "type": "datasource", + "id": "loki", + "name": "Loki", + "version": "2.x" + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e69fe6f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2025 @@ +{ + "name": "stvaults-watcher", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stvaults-watcher", + "version": "1.0.0", + "dependencies": { + "prom-client": "^15.1.0", + "viem": "^2.21.0" + }, + "devDependencies": { + "@grafana/grafana-foundation-sdk": "~0.0.12", + "@typescript-eslint/eslint-plugin": "^8.57.1", + "@typescript-eslint/parser": "^8.57.1", + "eslint": "^10.1.0", + "tsx": "^4.21.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@grafana/grafana-foundation-sdk": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@grafana/grafana-foundation-sdk/-/grafana-foundation-sdk-0.0.12.tgz", + "integrity": "sha512-9OXNTXblkwV9o0ToQtXd4GzUG5CmeMvUxTRVge2Xrws7+W/Ytc7fNF80Xs4vnEDRFBOIw7psZIJXpVHDVxVQaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ox": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.7.tgz", + "integrity": "sha512-zSQ/cfBdolj7U4++NAvH7sI+VG0T3pEohITCgcQj8KlawvTDY4vGVhDT64Atsm0d6adWfIYHDpu88iUBMMp+AQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/viem": { + "version": "2.47.6", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.6.tgz", + "integrity": "sha512-zExmbI99NGvMdYa7fmqSTLgkwh48dmhgEqFrUgkpL4kfG4XkVefZ8dZqIKVUhZo6Uhf0FrrEXOsHm9LUyIvI2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.7", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3358768 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "stvaults-watcher", + "version": "1.0.0", + "description": "Monitor for Lido stVaults. Prometheus metrics and Discord alerts", + "type": "module", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "test": "node --test tests/**/*.test.js", + "test:grafana": "tsx --test tests/grafana/build.test.ts tests/grafana/dashboard.test.ts tests/grafana/panels.test.ts", + "test:all": "npm test && npm run test:grafana", + "lint": "eslint . --ext .js,.ts", + "lint:fix": "eslint . --ext .js,.ts --fix", + "grafana:build": "tsx src/grafana/build.ts" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "prom-client": "^15.1.0", + "viem": "^2.21.0" + }, + "devDependencies": { + "@grafana/grafana-foundation-sdk": "~0.0.12", + "@typescript-eslint/eslint-plugin": "^8.57.1", + "@typescript-eslint/parser": "^8.57.1", + "eslint": "^10.1.0", + "tsx": "^4.21.0" + } +} diff --git a/src/abis.js b/src/abis.js new file mode 100644 index 0000000..28eb652 --- /dev/null +++ b/src/abis.js @@ -0,0 +1,70 @@ +import { parseAbi } from 'viem'; + +/** Human-readable ABIs for Lido V3 stVaults monitoring (read-only methods). */ + +export const vaultHubAbi = parseAbi([ + 'function isVaultHealthy(address vault) view returns (bool)', + 'function healthShortfallShares(address vault) view returns (uint256)', + 'function liabilityShares(address vault) view returns (uint256)', + 'function totalMintingCapacityShares(address vault, int256 deltaValue) view returns (uint256)', + 'function isReportFresh(address vault) view returns (bool)', + 'function withdrawableValue(address vault) view returns (uint256)', + 'function totalValue(address vault) view returns (uint256)', +]); + +export const vaultConnectionAbi = [{ + name: 'vaultConnection', + type: 'function', + stateMutability: 'view', + inputs: [{ name: '_vault', type: 'address' }], + outputs: [{ + name: '', + type: 'tuple', + components: [ + { name: 'owner', type: 'address' }, + { name: 'shareLimit', type: 'uint96' }, + { name: 'vaultIndex', type: 'uint96' }, + { name: 'disconnectInitiatedTs', type: 'uint48' }, + { name: 'reserveRatioBP', type: 'uint16' }, + { name: 'forcedRebalanceThresholdBP', type: 'uint16' }, + { name: 'infraFeeBP', type: 'uint16' }, + { name: 'liquidityFeeBP', type: 'uint16' }, + { name: 'reservationFeeBP', type: 'uint16' }, + { name: 'beaconChainDepositsPauseIntent', type: 'bool' }, + ], + }], +}]; + +export const stakingVaultAbi = parseAbi([ + 'function availableBalance() view returns (uint256)', + 'function stagedBalance() view returns (uint256)', + 'function valuation() view returns (uint256)', + 'function nodeOperator() view returns (address)', +]); + +export const withdrawalQueueAbi = parseAbi([ + 'function unfinalizedRequestsNumber() view returns (uint256)', + 'function unfinalizedAssets() view returns (uint256)', + 'function getLastRequestId() view returns (uint256)', + 'function getLastFinalizedRequestId() view returns (uint256)', +]); + +export const poolAbi = parseAbi([ + 'function WITHDRAWAL_QUEUE() view returns (address)', + 'function DASHBOARD() view returns (address)', +]); + +export const dashboardAbi = parseAbi([ + 'function accruedFee() view returns (uint256)', + 'function pdgPolicy() view returns (uint8)', +]); + +export const stEthAbi = parseAbi([ + 'function getPooledEthByShares(uint256 shares) view returns (uint256)', +]); + +export const pdgAbi = parseAbi([ + 'function nodeOperatorBalance(address _nodeOperator) view returns (uint128 total, uint128 locked)', + 'function unlockedBalance(address _nodeOperator) view returns (uint256)', + 'function pendingActivations(address _vault) view returns (uint256)', +]); diff --git a/src/chain.js b/src/chain.js new file mode 100644 index 0000000..fcd931e --- /dev/null +++ b/src/chain.js @@ -0,0 +1,19 @@ +import { createPublicClient, http } from 'viem'; + +/** + * Create a viem public client for read-only contract calls. + * @param {string} rpcUrl - Ethereum RPC URL + * @param {number} chainId - Chain ID + * @returns {import('viem').PublicClient} + */ +export function createClient(rpcUrl, chainId) { + return createPublicClient({ + chain: { + id: chainId, + name: `Chain ${chainId}`, + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + }, + transport: http(rpcUrl), + }); +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..b3aa30e --- /dev/null +++ b/src/config.js @@ -0,0 +1,215 @@ +/** + * Load and validate configuration from environment variables. + * Network-specific constants (chainId, contract addresses) are read from + * src/networks.json — the user only needs to set CHAIN=mainnet|hoodi. + */ + +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const NETWORKS = require('./networks.json'); + +const SUPPORTED_CHAINS = Object.keys(NETWORKS); + +const requiredEnvVars = [ + 'RPC_URL', + 'CHAIN', + 'VAULT_CONFIGS', +]; + +const optionalEnvVars = { + POLL_INTERVAL_MIN: 1, + METRICS_PORT: 9600, + ALERT_COOLDOWN_MIN: 30, + DISCORD_USERNAME: 'stvaults-watcher', + DISCORD_AVATAR_URL: + 'https://raw.githubusercontent.com/stakely/stvaults-watcher/main/static/avatar.png', + // Default inactive ETH threshold (in ETH). + // Note: on-chain comparisons operate in wei, so we convert this when loading config. + INACTIVE_ETH_THRESHOLD: 2, + HEALTH_WARNING_THRESHOLD: 107, + HEALTH_CRITICAL_THRESHOLD: 102, + UTILIZATION_WARNING_THRESHOLD: 95, + FORCED_REBALANCE_THRESHOLD_BP: 1000, // 10%, used for health factor calculation if vaultConnection not read +}; + +/** + * Parse a human ETH amount (e.g. "32", "32.5") into wei (1e18 fixed-point). + * This is used for `INACTIVE_ETH_THRESHOLD` so operators can work in ETH. + * @param {string} raw + * @returns {bigint} + */ +function parseEthToWeiBigInt(raw) { + const s = String(raw ?? '').trim(); + if (!s) throw new Error('empty'); + if (/[eE]/.test(s)) throw new Error('scientific notation is not supported'); + if (s.startsWith('-')) throw new Error('negative not supported'); + + // Integer ETH + if (/^\d+$/.test(s)) { + return BigInt(s) * 10n ** 18n; + } + + // Decimal ETH with up to 18 decimals + const m = s.match(/^(\d+)?\.(\d+)$/); + if (!m) throw new Error('invalid eth format'); + + const intPart = m[1] ? BigInt(m[1]) : 0n; + const fracRaw = m[2]; + if (fracRaw.length > 18) throw new Error('too many decimals'); + const fracPadded = fracRaw.padEnd(18, '0'); + + return intPart * 10n ** 18n + BigInt(fracPadded); +} + +/** + * @typedef {Object} VaultConfig + * @property {string} vault - Vault contract address + * @property {string} [pool] - Pool (DeFi Wrapper) address. Optional; vaults without pool skip WQ/dashboard discovery. + * @property {string} vault_name - Human-readable vault id for metrics and alerts + * @property {string} [withdrawalQueue] - Optional, auto-discovered from pool if empty + * @property {string} [dashboard] - Optional, auto-discovered from pool if empty + */ + +/** + * @typedef {Object} Config + * @property {string} rpcUrl + * @property {number} chainId + * @property {string} chain - Chain name for metrics (e.g. mainnet, hoodi) + * @property {string} explorerUrl - Block explorer base URL for this chain + * @property {VaultConfig[]} vaults + * @property {string} vaultHubAddress + * @property {string} stEthAddress + * @property {string} pdgAddress - PredepositGuarantee contract address + * @property {number} pollIntervalMs + * @property {number} metricsPort + * @property {string} [discordWebhookUrl] + * @property {number} alertCooldownMs + * @property {bigint} inactiveEthThresholdWei + * @property {number} healthWarningThreshold + * @property {number} healthCriticalThreshold + * @property {number} utilizationWarningThreshold + */ + +/** + * Parse VAULT_CONFIGS JSON array and validate each entry. + * @param {string} raw + * @returns {VaultConfig[]} + */ +function parseVaultConfigs(raw) { + let arr; + try { + arr = JSON.parse(raw); + } catch (e) { + throw new Error(`VAULT_CONFIGS must be a valid JSON array: ${e.message}`); + } + if (!Array.isArray(arr) || arr.length === 0) { + throw new Error('VAULT_CONFIGS must be a non-empty JSON array'); + } + const vaults = []; + for (let i = 0; i < arr.length; i++) { + const v = arr[i]; + if (!v || typeof v !== 'object') { + throw new Error(`VAULT_CONFIGS[${i}] must be an object`); + } + const vault = String(v.vault ?? '').trim(); + const pool = String(v.pool ?? '').trim(); + const vaultName = String(v.vault_name ?? `vault-${i}`).trim() || `vault-${i}`; + if (!vault) { + throw new Error(`VAULT_CONFIGS[${i}] must have a "vault" address`); + } + if (!/^0x[a-fA-F0-9]{40}$/.test(vault)) { + throw new Error(`VAULT_CONFIGS[${i}] vault must be a valid 0x40-hex address`); + } + if (pool && !/^0x[a-fA-F0-9]{40}$/.test(pool)) { + throw new Error(`VAULT_CONFIGS[${i}] pool must be a valid 0x40-hex address`); + } + const withdrawalQueue = String(v.withdrawalQueue ?? '').trim(); + const dashboard = String(v.dashboard ?? '').trim(); + if (withdrawalQueue && !/^0x[a-fA-F0-9]{40}$/.test(withdrawalQueue)) { + throw new Error(`VAULT_CONFIGS[${i}] withdrawalQueue must be a valid 0x40-hex address`); + } + if (dashboard && !/^0x[a-fA-F0-9]{40}$/.test(dashboard)) { + throw new Error(`VAULT_CONFIGS[${i}] dashboard must be a valid 0x40-hex address`); + } + vaults.push({ + vault, + pool, + vault_name: vaultName, + withdrawalQueue, + dashboard, + }); + } + return vaults; +} + +/** + * @returns {Config} + */ +export function loadConfig() { + const missing = requiredEnvVars.filter((k) => !process.env[k]?.trim()); + if (missing.length > 0) { + throw new Error(`Missing required env vars: ${missing.join(', ')}`); + } + + const chain = process.env.CHAIN.trim(); + const network = NETWORKS[chain]; + if (!network) { + throw new Error( + `Unsupported CHAIN "${chain}". Supported values: ${SUPPORTED_CHAINS.join(', ')}` + ); + } + + const { chainId, explorerUrl, contracts } = network; + + const vaults = parseVaultConfigs(process.env.VAULT_CONFIGS); + + const pollIntervalMin = parseFloat(process.env.POLL_INTERVAL_MIN) || optionalEnvVars.POLL_INTERVAL_MIN; + const pollIntervalMs = Math.round(pollIntervalMin * 60_000); + const metricsPort = parseInt(process.env.METRICS_PORT, 10) || optionalEnvVars.METRICS_PORT; + const alertCooldownMin = parseFloat(process.env.ALERT_COOLDOWN_MIN) || optionalEnvVars.ALERT_COOLDOWN_MIN; + const alertCooldownMs = Math.round(alertCooldownMin * 60_000); + const healthWarningThreshold = parseInt(process.env.HEALTH_WARNING_THRESHOLD, 10) || optionalEnvVars.HEALTH_WARNING_THRESHOLD; + const healthCriticalThreshold = parseInt(process.env.HEALTH_CRITICAL_THRESHOLD, 10) || optionalEnvVars.HEALTH_CRITICAL_THRESHOLD; + const utilizationWarningThreshold = parseInt(process.env.UTILIZATION_WARNING_THRESHOLD, 10) || optionalEnvVars.UTILIZATION_WARNING_THRESHOLD; + + let inactiveEthThresholdWei = parseEthToWeiBigInt(optionalEnvVars.INACTIVE_ETH_THRESHOLD); + + if ('INACTIVE_ETH_THRESHOLD' in process.env) { + const raw = (process.env.INACTIVE_ETH_THRESHOLD ?? '').trim(); + if (!raw) throw new Error('INACTIVE_ETH_THRESHOLD must not be empty'); + try { + inactiveEthThresholdWei = parseEthToWeiBigInt(raw); + } catch (e) { + throw new Error(`INACTIVE_ETH_THRESHOLD is invalid: ${e.message}`); + } + } + + const forcedRebalanceThresholdBP = + parseInt(process.env.FORCED_REBALANCE_THRESHOLD_BP, 10) || optionalEnvVars.FORCED_REBALANCE_THRESHOLD_BP; + + const discordUsername = process.env.DISCORD_USERNAME?.trim() || optionalEnvVars.DISCORD_USERNAME; + const discordAvatarUrl = process.env.DISCORD_AVATAR_URL?.trim() || optionalEnvVars.DISCORD_AVATAR_URL; + + return { + rpcUrl: process.env.RPC_URL.trim(), + chainId, + chain, + explorerUrl, + vaults, + vaultHubAddress: contracts.vaultHub, + stEthAddress: contracts.stEth, + pdgAddress: contracts.pdg, + pollIntervalMs, + metricsPort, + discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL?.trim() || undefined, + discordUsername, + discordAvatarUrl, + alertCooldownMs, + inactiveEthThresholdWei, + healthWarningThreshold, + healthCriticalThreshold, + utilizationWarningThreshold, + forcedRebalanceThresholdBP, + }; +} diff --git a/src/grafana/build.ts b/src/grafana/build.ts new file mode 100644 index 0000000..afec97e --- /dev/null +++ b/src/grafana/build.ts @@ -0,0 +1,218 @@ +/** + * Compiles the dashboard definition to grafana/dashboard.json. + * + * Post-processes the SDK output because the Grafana Foundation SDK does not + * support `__inputs` natively. The post-process: + * 1. Replaces the stat "Contracts" panel with a proper table panel. + * 2. Injects the hidden `explorer_base` query variable. + * 3. Patches the Logs panel with syntaxHighlighting. + * 4. Removes SDK-generated datasource variables and injects `__inputs` / + * `__requires` so Grafana prompts for Prometheus and Loki on import. + * + * Invoked via: npm run grafana:build + */ + +import { writeFileSync } from "fs"; +import { resolve } from "path"; +import { buildDashboard } from "./dashboard.js"; + +function replaceContractsPanelWithTable(dashboard: any) { + const panels = dashboard.panels ?? []; + const idx = panels.findIndex((p: any) => p.title === "Contracts"); + if (idx === -1) return; + const old = panels[idx]; + const gridPos = old.gridPos ?? { x: 0, y: 12, w: 24, h: 6 }; + + const explorerLink = { + title: "Open in Explorer", + url: "${explorer_base}/address/${__value.raw}", + targetBlank: true, + }; + + // One row per contract type using labelsToFields (rows mode). + // This avoids transpose entirely, which has a Grafana bug where the first row + // always gets the column header as its value instead of the actual data. + panels[idx] = { + type: "table", + title: "Contracts", + description: "Contract addresses. Click an address to open in the block explorer.", + datasource: { type: "prometheus", uid: "${DS_PROMETHEUS}" }, + gridPos, + targets: [ + { + expr: 'lido_vault_contracts_info{chain=~"$chain",vault_name=~"$vault_name"}', + legendFormat: "{{vault_name}}", + refId: "A", + instant: true, + // No format:"table"; labelsToFields needs the time-series frame with labels attached + }, + ], + transformations: [ + { + // Pivots each Prometheus label into its own row: Name=label_key, Value=label_value + id: "labelsToFields", + options: { + mode: "rows", + keepLabels: ["vault_addr", "pool_addr", "wq_addr", "dashboard_addr"], + }, + }, + { + // labelsToFields emits lowercase "label" and "value" columns + id: "organize", + options: { + excludeByName: { Time: true }, + renameByName: { label: "Name", value: "Contract" }, + }, + }, + { + // Keep only rows where the contract address is not null (hides pool/WQ/dashboard rows for vaults without those contracts) + id: "filterByValue", + options: { + type: "include", + match: "any", + filters: [{ fieldName: "Contract", config: { id: "isNotNull" } }], + }, + }, + ], + fieldConfig: { + defaults: { custom: { align: "auto", cellOptions: { type: "auto" } } }, + overrides: [ + { + matcher: { id: "byName", options: "Contract" }, + properties: [{ id: "links", value: [explorerLink] }], + }, + { + matcher: { id: "byName", options: "Name" }, + properties: [ + { + id: "mappings", + value: [ + { type: "value", options: { vault_addr: { text: "Vault", color: "green", index: 0 } } }, + { type: "value", options: { pool_addr: { text: "Pool", color: "blue", index: 1 } } }, + { type: "value", options: { wq_addr: { text: "Withdrawal Queue", color: "orange", index: 2 } } }, + { type: "value", options: { dashboard_addr: { text: "Dashboard", color: "purple", index: 3 } } }, + ], + }, + ], + }, + ], + }, + options: { showHeader: true, cellHeight: "sm", footer: { show: false, reducer: ["sum"], countRows: false, fields: "" } }, + }; +} + +function addExplorerBaseVariable(dashboard: any) { + const list = dashboard.templating?.list ?? []; + // Query variable: reads explorer_url label from stvaults_watcher_info filtered by $chain. + // When the user switches chain, Grafana re-runs label_values() and automatically picks + // the correct explorer URL (https://etherscan.io or https://hoodi.etherscan.io). + list.push({ + type: "query", + name: "explorer_base", + label: "Explorer", + hide: 2, + skipUrlSync: false, + datasource: { type: "prometheus", uid: "${DS_PROMETHEUS}" }, + definition: `label_values(stvaults_watcher_info{chain="$chain"}, explorer_url)`, + query: { + query: `label_values(stvaults_watcher_info{chain="$chain"}, explorer_url)`, + refId: "StandardVariableQuery", + }, + refresh: 2, + sort: 0, + includeAll: false, + multi: false, + allValue: null, + current: {}, + options: [], + }); +} + +function addRepositoryLink(dashboard: any) { + const links = dashboard.links ?? []; + links.push({ + title: "GitHub Repository", + url: "https://github.com/Stakely/stvaults-watcher", + targetBlank: true, + icon: "external link", + }); + dashboard.links = links; +} + +function patchPollErrorsPanel(dashboard: any) { + const panel = (dashboard.panels ?? []).find((p: any) => p.title === "Poll errors"); + if (!panel) return; + panel.fieldConfig = panel.fieldConfig ?? {}; + panel.fieldConfig.defaults = panel.fieldConfig.defaults ?? {}; + panel.fieldConfig.defaults.noValue = "0"; +} + +function patchLogsPanel(dashboard: any) { + const panels = dashboard.panels ?? []; + const logsPanel = panels.find((p: any) => p.type === "logs"); + if (!logsPanel) return; + logsPanel.options = logsPanel.options ?? {}; + // Enable predefined coloring scheme for log line highlighting (not exposed by SDK yet) + logsPanel.options.syntaxHighlighting = true; +} + +const built = buildDashboard(); +replaceContractsPanelWithTable(built); +addExplorerBaseVariable(built); +addRepositoryLink(built); +patchPollErrorsPanel(built); +patchLogsPanel(built); + +// Names used by DatasourceVariableBuilder in dashboard.ts / panels.ts. +// The SDK emits these as `type:"datasource"` entries in templating.list; we remove +// them because datasource selection is handled via __inputs at import time instead. +const DS_VAR_NAMES = new Set(["DS_PROMETHEUS", "DS_LOKI"]); + +function removeSdkDatasourceVars(dashboard: any) { + // The SDK always emits `type:"datasource"` entries in templating.list for each + // DatasourceVariableBuilder. Grafana renders these as visible dropdown variables; + // we want datasource selection to happen only at import time via __inputs. + const list = dashboard?.templating?.list; + if (Array.isArray(list)) { + dashboard.templating.list = list.filter( + (v: any) => !(v?.type === "datasource" && DS_VAR_NAMES.has(v?.name)), + ); + } +} + +function addGrafanaInputs(dashboard: any) { + // Grafana's import wizard prompts for datasource selection only when the JSON + // declares top-level `__inputs` entries. Each entry maps a placeholder name + // (e.g. ${DS_PROMETHEUS}) to a real datasource chosen by the user at import time. + dashboard.__inputs = [ + { + name: "DS_PROMETHEUS", + label: "Prometheus", + type: "datasource", + pluginId: "prometheus", + pluginName: "Prometheus", + current: { selected: false, value: "" }, + }, + { + name: "DS_LOKI", + label: "Loki", + type: "datasource", + pluginId: "loki", + pluginName: "Loki", + current: { selected: false, value: "" }, + }, + ]; + + dashboard.__requires = [ + { type: "grafana", id: "grafana", name: "Grafana", version: "10.x" }, + { type: "datasource", id: "prometheus", name: "Prometheus", version: "2.x" }, + { type: "datasource", id: "loki", name: "Loki", version: "2.x" }, + ]; +} + +removeSdkDatasourceVars(built); +addGrafanaInputs(built); + +const outPath = resolve("grafana", "dashboard.json"); +writeFileSync(outPath, JSON.stringify(built, null, 2), "utf-8"); +console.log(`Dashboard written to ${outPath}`); diff --git a/src/grafana/dashboard.ts b/src/grafana/dashboard.ts new file mode 100644 index 0000000..ca717c5 --- /dev/null +++ b/src/grafana/dashboard.ts @@ -0,0 +1,561 @@ +/** + * stvaults-watcher - Grafana dashboard defined as code. + * + * Uses the Grafana Foundation SDK builder pattern. + * Each row combines stat/gauge panels with inline timeseries. + * Variables: Prometheus datasource selector + chain filter (hoodi/mainnet). + * + * Build output: grafana/dashboard.json (via `npm run grafana:build`) + */ + +import { + DashboardBuilder, + DatasourceVariableBuilder, + QueryVariableBuilder, + RowBuilder, + VariableHide, +} from "@grafana/grafana-foundation-sdk/dashboard"; +import { PanelBuilder as LogsPanelBuilder } from "@grafana/grafana-foundation-sdk/logs"; +import { DataqueryBuilder as LokiQueryBuilder } from "@grafana/grafana-foundation-sdk/loki"; +import { PanelBuilder as RawStatBuilder } from "@grafana/grafana-foundation-sdk/stat"; +import { DataqueryBuilder } from "@grafana/grafana-foundation-sdk/prometheus"; +import { ReduceDataOptionsBuilder } from "@grafana/grafana-foundation-sdk/common"; +import { statPanel, gaugePanel, timeseriesPanel, DATASOURCE_VAR } from "./panels.js"; + +const LOKI_DS_VAR = "DS_LOKI"; +const LOKI_DS = { type: "loki" as const, uid: `\${${LOKI_DS_VAR}}` }; + +// --------------------------------------------------------------------------- +// Threshold palettes +// --------------------------------------------------------------------------- + +const GREEN = "#73BF69"; +const YELLOW = "#FF9830"; +const ORANGE = "#FF780A"; +const RED = "#F2495C"; +const BLUE = "#5794F2"; +const PURPLE = "#B877D9"; + +const healthThresholds = [{ value: null as any, color: RED }, { value: 105, color: YELLOW }, { value: 120, color: GREEN }]; +const utilizationThresh = [{ value: null as any, color: GREEN }, { value: 80, color: YELLOW }, { value: 95, color: RED }]; +const booleanGreen = [{ value: null as any, color: RED }, { value: 1, color: GREEN }]; +const booleanFresh = [{ value: null as any, color: BLUE }, { value: 1, color: GREEN }]; +const ethThresholds = [{ value: null as any, color: BLUE }]; +const boolMappings = [ + { type: "value" as const, options: { "0": { text: "NO", color: RED, index: 0 }, "1": { text: "YES", color: GREEN, index: 1 } } }, +]; +const freshMappings = [ + { type: "value" as const, options: { "0": { text: "STALE", color: BLUE, index: 0 }, "1": { text: "FRESH", color: GREEN, index: 1 } } }, +]; +const pdgPolicyMappings = [ + { + type: "value" as const, + options: { + "0": { text: "STRICT", color: BLUE, index: 0 }, + "1": { text: "ALLOW_PROVE", color: YELLOW, index: 1 }, + "2": { text: "ALLOW_DEPOSIT_AND_PROVE", color: GREEN, index: 2 }, + }, + }, +]; + +// --------------------------------------------------------------------------- +// Layout (Grafana grid = 24 cols) +// --------------------------------------------------------------------------- + +function pos(x: number, y: number, w: number, h: number) { + return { x, y, w, h }; +} + +function q(metric: string) { + return `${metric}{chain=~"$chain",vault_name=~"$vault_name"}`; +} + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- + +export function buildDashboard(): object { + const builder = new DashboardBuilder("stVaults Watcher") + .uid("stvaults-watcher") + .tags(["ETHEREUM", "LIDO"]) + .refresh("1m") + .time({ from: "now-6h", to: "now" }) + .timezone("browser") + .withVariable( + new DatasourceVariableBuilder(DATASOURCE_VAR) + .label("Prometheus") + .type("prometheus") + .hide(VariableHide.HideVariable), + ) + .withVariable( + new QueryVariableBuilder("chain") + .label("Chain") + .datasource({ type: "prometheus", uid: `\${${DATASOURCE_VAR}}` }) + .query("label_values(lido_vault_total_value_eth, chain)") + .refresh(1) + .sort(1) + .includeAll(false), + ) + .withVariable( + new QueryVariableBuilder("vault_name") + .label("Vault") + .datasource({ type: "prometheus", uid: `\${${DATASOURCE_VAR}}` }) + .query('label_values(lido_vault_total_value_eth{chain=~"$chain"}, vault_name)') + .refresh(2) + .sort(1) + .includeAll(true) + .allValue(".*") + .allowCustomValue(false), + ) + .withVariable( + new DatasourceVariableBuilder(LOKI_DS_VAR) + .label("Loki") + .type("loki") + .hide(VariableHide.HideVariable), + ); + + let y = 0; + const S = 5; + const G = 7; + const T = 8; + + // ==================== 📊 Status ==================== + builder.withRow(new RowBuilder("📊 Status")); + y += 1; + + builder + .withPanel(statPanel({ + title: "Watcher version", + expr: `stvaults_watcher_info{chain="$chain"}`, + legendLabel: "v{{version}}", + colorMode: "background_solid", graphMode: "none", textMode: "name", + thresholdSteps: [{ value: null as any, color: PURPLE }], + gridPos: pos(0, y, 4, S), + })) + .withPanel(statPanel({ + title: "Watcher alive", + expr: `(time() - stvaults_watcher_last_poll_timestamp{chain=~"$chain"}) < bool 300`, + legendLabel: "{{chain}}", + description: "YES if the watcher has polled in the last 5 minutes. Calculated as: (current time − last poll timestamp) < 300 seconds.", + colorMode: "background_solid", graphMode: "none", + thresholdSteps: booleanGreen, + valueMappings: boolMappings as any, + gridPos: pos(4, y, 4, S), + })) + .withPanel(statPanel({ + title: "Vault healthy", + expr: q("lido_vault_is_healthy"), + description: "Whether the vault is considered healthy by VaultHub (isVaultHealthy on-chain).", + colorMode: "background_solid", graphMode: "none", + thresholdSteps: booleanGreen, + valueMappings: boolMappings as any, + gridPos: pos(8, y, 4, S), + })) + .withPanel(statPanel({ + title: "Unfinalized requests", + expr: q("lido_wq_unfinalized_requests"), + description: "Number of withdrawal requests in the queue not yet finalized on L1.", + decimals: 0, + colorMode: "background_solid", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }, { value: 1, color: YELLOW }, { value: 10, color: RED }], + gridPos: pos(12, y, 4, S), + })) + .withPanel(statPanel({ + title: "Inactive ETH", + expr: q("lido_vault_inactive_eth"), + description: "ETH in the vault that is not yet staked (available minus staged). Used for withdrawals and rebalancing.", + unit: "eth", decimals: 4, + colorMode: "background_solid", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }, { value: 1, color: YELLOW }, { value: 32, color: RED }], + gridPos: pos(16, y, 4, S), + })) + .withPanel(statPanel({ + title: "Withdrawal deficit", + expr: `clamp_min(lido_wq_unfinalized_assets_eth{chain=~"$chain",vault_name=~"$vault_name"} - lido_vault_available_balance_eth{chain=~"$chain",vault_name=~"$vault_name"}, 0)`, + unit: "eth", decimals: 4, + description: "ETH needed from validators to cover pending withdrawals (0 = enough liquidity)", + colorMode: "background_solid", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }, { value: 0.01, color: YELLOW }, { value: 1, color: RED }], + gridPos: pos(20, y, 4, S), + })); + y += S; + + // ==================== 💰 Vault state ==================== + builder.withRow(new RowBuilder("💰 Vault state")); + y += 1; + + const DS_REF = { type: "prometheus" as const, uid: `\${${DATASOURCE_VAR}}` }; + const ci = `lido_vault_contracts_info{chain=~"$chain",vault_name=~"$vault_name"}`; + builder + .withPanel(statPanel({ + title: "Total value", + expr: q("lido_vault_total_value_eth"), + description: "Total value of the vault (VaultHub totalValue) in ETH.", + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "area", + thresholdSteps: ethThresholds, + gridPos: pos(0, y, 4, S), + })) + .withPanel(statPanel({ + title: "Available balance", + expr: q("lido_vault_available_balance_eth"), + description: "ETH available in the StakingVault (liquid, not yet staged for staking).", + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "area", + thresholdSteps: ethThresholds, + gridPos: pos(4, y, 4, S), + })) + .withPanel(statPanel({ + title: "Staged balance", + expr: q("lido_vault_staged_balance_eth"), + description: "ETH staged in the StakingVault (pending to be staked).", + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "area", + thresholdSteps: ethThresholds, + gridPos: pos(8, y, 4, S), + })) + .withPanel(statPanel({ + title: "Withdrawable value", + expr: q("lido_vault_withdrawable_value_eth"), + description: "ETH that can be withdrawn (VaultHub withdrawableValue).", + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "area", + thresholdSteps: ethThresholds, + gridPos: pos(12, y, 4, S), + })) + .withPanel(statPanel({ + title: "Node Operator fee", + expr: q("lido_vault_node_operator_fee_eth"), + description: "Undisbursed node operator fee accrued in Dashboard (accruedFee).", + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "area", + thresholdSteps: ethThresholds, + gridPos: pos(16, y, 4, S), + })) + .withPanel(statPanel({ + title: "Staking efficiency", + expr: `(${q("lido_vault_total_value_eth")} - ${q("lido_vault_inactive_eth")}) / ${q("lido_vault_total_value_eth")} * 100`, + description: "% of vault ETH actively staked and generating yield. Computed as (total_value − inactive_eth) / total_value × 100.", + unit: "percent", decimals: 2, + colorMode: "background_solid", graphMode: "none", + thresholdSteps: [{ value: null as any, color: RED }, { value: 80, color: YELLOW }, { value: 96, color: GREEN }], + gridPos: pos(20, y, 4, S), + })); + y += S; + + builder + .withPanel(timeseriesPanel({ + title: "Total value (ETH)", + expr: q("lido_vault_total_value_eth"), + unit: "eth", decimals: 4, + gridPos: pos(0, y, 12, T), + })) + .withPanel(timeseriesPanel({ + title: "Available vs Staged (ETH)", + expr: q("lido_vault_available_balance_eth"), + unit: "eth", decimals: 4, + gridPos: pos(12, y, 12, T), + })); + y += T; + + // ==================== 📋 Contracts ==================== + builder.withRow(new RowBuilder("📋 Contracts")); + y += 1; + builder.withPanel( + new RawStatBuilder() + .title("Contracts") + .description("Replaced by build with table.") + .datasource(DS_REF) + .reduceOptions(new ReduceDataOptionsBuilder().calcs(["lastNotNull"])) + .withTarget(new DataqueryBuilder().expr(ci).legendFormat("{{vault_addr}}")) + .gridPos(pos(0, y, 24, 6)), + ); + y += 6; + + // ==================== 🏥 Health ==================== + builder.withRow(new RowBuilder("🏥 Health")); + y += 1; + + builder + .withPanel(gaugePanel({ + title: "Health factor", + expr: q("lido_vault_health_factor"), + unit: "percent", decimals: 2, + min: 0, max: 200, + thresholdSteps: healthThresholds, + valueMappings: [ + { type: "range" as const, options: { from: 9999, to: null as any, result: { text: "∞", color: GREEN } } }, + ] as any, + gridPos: pos(0, y, 6, G), + })) + .withPanel(gaugePanel({ + title: "stETH Minted", + expr: q("lido_vault_utilization_ratio"), + description: "% of stETH minting capacity used (liabilityShares / mintingCapacityShares)", + unit: "percent", decimals: 2, + min: 0, max: 100, + thresholdSteps: utilizationThresh, + gridPos: pos(6, y, 6, G), + })) + .withPanel(statPanel({ + title: "Forced rebalance threshold", + expr: q("lido_vault_forced_rebalance_threshold"), + unit: "percent", decimals: 2, + description: "If Health Factor falls below 100% (based on this threshold), the vault is subject to forced rebalancing", + colorMode: "none", graphMode: "none", + gridPos: pos(12, y, 3, G), + })) + .withPanel(statPanel({ + title: "Reserve ratio", + expr: q("lido_vault_reserve_ratio"), + unit: "percent", decimals: 2, + description: "% of Total Value reserved as collateral; stETH cannot be minted against this amount", + colorMode: "none", graphMode: "none", + gridPos: pos(15, y, 3, G), + })) + .withPanel(statPanel({ + title: "Oracle report", + expr: q("lido_vault_report_fresh"), + colorMode: "background_solid", graphMode: "none", + thresholdSteps: booleanFresh, + valueMappings: freshMappings as any, + gridPos: pos(18, y, 3, G), + })) + .withPanel(statPanel({ + title: "Health shortfall", + expr: q("lido_vault_health_shortfall_shares"), + decimals: 0, + description: "Shares needed to restore health (0 = healthy)", + colorMode: "value", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }, { value: 1, color: RED }], + gridPos: pos(21, y, 3, G), + })); + y += G; + + builder + .withPanel(timeseriesPanel({ + title: "Health factor %", + // clamp_max: vaults with no minted stETH have +Inf health factor; cap at 9999 so the + // timeseries line stays visible. The raw +Inf value is preserved in Prometheus. + expr: `clamp_max(${q("lido_vault_health_factor")}, 9999)`, + unit: "percent", decimals: 2, + thresholdSteps: healthThresholds, + valueMappings: [ + { type: "range" as const, options: { from: 9999, to: null as any, result: { text: "∞", color: GREEN } } }, + ] as any, + gridPos: pos(0, y, 12, T), + })) + .withPanel(timeseriesPanel({ + title: "stETH Minted %", + expr: q("lido_vault_utilization_ratio"), + unit: "percent", decimals: 2, + thresholdSteps: utilizationThresh, + gridPos: pos(12, y, 12, T), + })); + y += T; + + // ==================== 🪙 stETH liability ==================== + builder.withRow(new RowBuilder("🪙 stETH liability")); + y += 1; + + builder + .withPanel(statPanel({ + title: "stETH liability", + expr: q("lido_vault_steth_liability_eth"), + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "area", + thresholdSteps: ethThresholds, + gridPos: pos(0, y, 8, S), + })) + .withPanel(statPanel({ + title: "Minting capacity", + expr: q("lido_vault_minting_capacity_eth"), + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "area", + thresholdSteps: ethThresholds, + gridPos: pos(8, y, 8, S), + })) + .withPanel(statPanel({ + title: "Withdrawable value", + expr: q("lido_vault_withdrawable_value_eth"), + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "area", + thresholdSteps: ethThresholds, + gridPos: pos(16, y, 8, S), + })); + y += S; + + // ==================== 📤 Withdrawal queue ==================== + builder.withRow(new RowBuilder("📤 Withdrawal queue")); + y += 1; + + builder + .withPanel(statPanel({ + title: "Unfinalized requests", + expr: q("lido_wq_unfinalized_requests"), + decimals: 0, + colorMode: "background_solid", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }, { value: 1, color: YELLOW }, { value: 10, color: RED }], + gridPos: pos(0, y, 6, S), + })) + .withPanel(statPanel({ + title: "Unfinalized assets", + expr: q("lido_wq_unfinalized_assets_eth"), + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "area", + thresholdSteps: ethThresholds, + gridPos: pos(6, y, 6, S), + })) + .withPanel(statPanel({ + title: "Last request ID", + expr: q("lido_wq_last_request_id"), + decimals: 0, + colorMode: "none", graphMode: "none", + gridPos: pos(12, y, 6, S), + })) + .withPanel(statPanel({ + title: "Last finalized ID", + expr: q("lido_wq_last_finalized_id"), + decimals: 0, + colorMode: "none", graphMode: "none", + gridPos: pos(18, y, 6, S), + })); + y += S; + + builder + .withPanel(timeseriesPanel({ + title: "Unfinalized assets (ETH)", + expr: q("lido_wq_unfinalized_assets_eth"), + unit: "eth", decimals: 4, + gridPos: pos(0, y, 24, T), + })); + y += T; + + // ==================== 🛡️ PDG ==================== + builder.withRow(new RowBuilder("🛡️ PDG (Predeposit Guarantee)")); + y += 1; + + builder + .withPanel(statPanel({ + title: "PDG Policy", + expr: q("lido_vault_pdg_policy"), + decimals: 0, + description: "Dashboard policy for PDG flow: 0=STRICT, 1=ALLOW_PROVE, 2=ALLOW_DEPOSIT_AND_PROVE.", + colorMode: "background_solid", graphMode: "none", + thresholdSteps: [{ value: null as any, color: BLUE }, { value: 1, color: YELLOW }, { value: 2, color: GREEN }], + valueMappings: pdgPolicyMappings as any, + gridPos: pos(0, y, 5, S), + })) + .withPanel(statPanel({ + title: "PDG Total", + expr: q("lido_vault_pdg_total_eth"), + description: "Total guarantee balance in PDG for this vault node operator.", + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }], + gridPos: pos(5, y, 5, S), + })) + .withPanel(statPanel({ + title: "PDG Locked", + expr: q("lido_vault_pdg_locked_eth"), + description: "Guarantee currently locked by active predeposits.", + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }], + gridPos: pos(10, y, 5, S), + })) + .withPanel(statPanel({ + title: "PDG Unlocked", + expr: q("lido_vault_pdg_unlocked_eth"), + description: "Guarantee available for new predeposits (total - locked).", + unit: "eth", decimals: 4, + colorMode: "value", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }, { value: 0.000001, color: ORANGE }], + gridPos: pos(15, y, 5, S), + })) + .withPanel(statPanel({ + title: "PDG Pending activations", + expr: q("lido_vault_pdg_pending_activations"), + decimals: 0, + description: "Validators in PREDEPOSITED/PROVEN stages awaiting activation.", + colorMode: "background_solid", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }, { value: 1, color: YELLOW }, { value: 10, color: RED }], + gridPos: pos(20, y, 4, S), + })); + y += S; + + builder + .withPanel(timeseriesPanel({ + title: "PDG Locked vs Unlocked (ETH)", + expr: q("lido_vault_pdg_locked_eth"), + unit: "eth", decimals: 4, + legendLabel: "{{vault_name}} locked", + additionalTargets: [ + { expr: q("lido_vault_pdg_unlocked_eth"), legendLabel: "{{vault_name}} unlocked" }, + ], + seriesColors: [ + { pattern: "locked$", color: YELLOW }, + { pattern: "unlocked$", color: GREEN }, + ], + gridPos: pos(0, y, 24, T), + })); + y += T; + + // ==================== 🤖 Watcher ==================== + builder.withRow(new RowBuilder("🤖 Watcher")); + y += 1; + + builder + .withPanel(statPanel({ + title: "Poll errors", + expr: `increase(stvaults_watcher_poll_errors_total{chain=~"$chain"}[$__range])`, + legendLabel: "{{chain}}", + decimals: 0, + colorMode: "value", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }, { value: 1, color: ORANGE }, { value: 10, color: RED }], + gridPos: pos(0, y, 6, S), + })) + .withPanel(statPanel({ + title: "Last successful poll", + expr: `stvaults_watcher_last_poll_timestamp{chain=~"$chain"} * 1000`, + legendLabel: "{{chain}}", + unit: "dateTimeFromNow", + colorMode: "none", graphMode: "none", + gridPos: pos(6, y, 6, S), + })) + .withPanel(statPanel({ + title: "Time since last poll", + expr: `time() - stvaults_watcher_last_poll_timestamp{chain=~"$chain"}`, + legendLabel: "{{chain}}", + unit: "s", decimals: 0, + colorMode: "value", graphMode: "none", + thresholdSteps: [{ value: null as any, color: GREEN }, { value: 120, color: YELLOW }, { value: 300, color: RED }], + gridPos: pos(12, y, 6, S), + })); + + y += S; + + // ==================== 📜 Logs ==================== + builder.withRow(new RowBuilder("📜 Logs")); + y += 1; + + builder.withPanel( + new LogsPanelBuilder() + .title("Watcher logs") + .datasource(LOKI_DS) + .showTime(true) + .wrapLogMessage(true) + .enableLogDetails(true) + .sortOrder("Ascending" as any) + .dedupStrategy("none" as any) + .fontSize("sm" as any) + .withTarget( + new LokiQueryBuilder() + .expr('{app="stvaults-watcher", chain=~"$chain"}') + .maxLines(200) + ) + .gridPos(pos(0, y, 24, 12)), + ); + + return builder.build(); +} diff --git a/src/grafana/panels.ts b/src/grafana/panels.ts new file mode 100644 index 0000000..3e901fb --- /dev/null +++ b/src/grafana/panels.ts @@ -0,0 +1,166 @@ +/** + * Reusable panel builder helpers for the Grafana dashboard. + * Supports stat, gauge and timeseries panels with thresholds, + * value mappings and proper sizing. + */ + +import { ReduceDataOptionsBuilder } from "@grafana/grafana-foundation-sdk/common"; +import { + FieldColorBuilder, + ThresholdsConfigBuilder, + type DataSourceRef, + type GridPos, + type Threshold, + type ValueMapping, +} from "@grafana/grafana-foundation-sdk/dashboard"; +import { DataqueryBuilder } from "@grafana/grafana-foundation-sdk/prometheus"; +import { PanelBuilder as StatPanelBuilder } from "@grafana/grafana-foundation-sdk/stat"; +import { PanelBuilder as GaugePanelBuilder } from "@grafana/grafana-foundation-sdk/gauge"; +import { PanelBuilder as TimeseriesPanelBuilder } from "@grafana/grafana-foundation-sdk/timeseries"; + +export const DATASOURCE_VAR = "DS_PROMETHEUS"; + +const DS: DataSourceRef = { + type: "prometheus", + uid: `\${${DATASOURCE_VAR}}`, +}; + +function reduce() { + return new ReduceDataOptionsBuilder().calcs(["lastNotNull"]); +} + +function thresholds(steps: Threshold[]) { + return new ThresholdsConfigBuilder() + .mode("absolute" as any) + .steps(steps); +} + +// --------------------------------------------------------------------------- +// Panel option types +// --------------------------------------------------------------------------- + +export interface StatOpts { + title: string; + expr: string; + unit?: string; + description?: string; + legendLabel?: string; + decimals?: number; + colorMode?: "background" | "background_solid" | "value" | "none"; + graphMode?: "area" | "line" | "none"; + textMode?: "auto" | "value" | "value_and_name" | "name" | "none"; + thresholdSteps?: Threshold[]; + valueMappings?: ValueMapping[]; + gridPos?: GridPos; +} + +export interface GaugeOpts { + title: string; + expr: string; + unit?: string; + description?: string; + legendLabel?: string; + decimals?: number; + min?: number; + max?: number; + thresholdSteps?: Threshold[]; + valueMappings?: ValueMapping[]; + gridPos?: GridPos; +} + +export interface TsOpts { + title: string; + expr: string; + unit?: string; + legendLabel?: string; + decimals?: number; + thresholdSteps?: Threshold[]; + valueMappings?: ValueMapping[]; + gridPos?: GridPos; + additionalTargets?: { expr: string; legendLabel?: string }[]; + seriesColors?: { pattern: string; color: string }[]; +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +export function statPanel(o: StatOpts): StatPanelBuilder { + const p = new StatPanelBuilder() + .title(o.title) + .datasource(DS) + .reduceOptions(reduce()) + .withTarget(new DataqueryBuilder().expr(o.expr).legendFormat(o.legendLabel ?? "{{vault_name}}")); + + if (o.unit) p.unit(o.unit); + if (o.description) p.description(o.description); + if (o.decimals !== undefined) p.decimals(o.decimals); + if (o.gridPos) p.gridPos(o.gridPos); + if (o.graphMode) p.graphMode(o.graphMode as any); + if (o.colorMode) p.colorMode(o.colorMode as any); + if (o.textMode) p.textMode(o.textMode as any); + if (o.valueMappings) p.mappings(o.valueMappings); + + if (o.thresholdSteps) { + p.thresholds(thresholds(o.thresholdSteps)); + p.colorScheme(new FieldColorBuilder().mode("thresholds" as any)); + } + + return p; +} + +export function gaugePanel(o: GaugeOpts): GaugePanelBuilder { + const p = new GaugePanelBuilder() + .title(o.title) + .datasource(DS) + .reduceOptions(reduce()) + .showThresholdMarkers(true) + .showThresholdLabels(false) + .withTarget(new DataqueryBuilder().expr(o.expr).legendFormat(o.legendLabel ?? "{{vault_name}}")); + + if (o.unit) p.unit(o.unit); + if (o.description) p.description(o.description); + if (o.decimals !== undefined) p.decimals(o.decimals); + if (o.gridPos) p.gridPos(o.gridPos); + if (o.min !== undefined) p.min(o.min); + if (o.max !== undefined) p.max(o.max); + if (o.valueMappings) p.mappings(o.valueMappings); + + if (o.thresholdSteps) { + p.thresholds(thresholds(o.thresholdSteps)); + p.colorScheme(new FieldColorBuilder().mode("thresholds" as any)); + } + + return p; +} + +export function timeseriesPanel(o: TsOpts): TimeseriesPanelBuilder { + const p = new TimeseriesPanelBuilder() + .title(o.title) + .datasource(DS) + .lineWidth(2) + .fillOpacity(15) + .withTarget(new DataqueryBuilder().expr(o.expr).legendFormat(o.legendLabel ?? "{{vault_name}}")); + + if (o.unit) p.unit(o.unit); + if (o.decimals !== undefined) p.decimals(o.decimals); + if (o.gridPos) p.gridPos(o.gridPos); + if (o.valueMappings) p.mappings(o.valueMappings); + if (o.additionalTargets) { + for (const t of o.additionalTargets) { + p.withTarget(new DataqueryBuilder().expr(t.expr).legendFormat(t.legendLabel ?? "{{vault_name}}")); + } + } + if (o.seriesColors) { + for (const s of o.seriesColors) { + p.overrideByRegexp(s.pattern, [{ id: "color", value: { mode: "fixed", fixedColor: s.color } }]); + } + } + + if (o.thresholdSteps) { + p.thresholds(thresholds(o.thresholdSteps)); + p.colorScheme(new FieldColorBuilder().mode("thresholds" as any)); + } + + return p; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..ca950a8 --- /dev/null +++ b/src/index.js @@ -0,0 +1,177 @@ +import { readFileSync } from 'fs'; +import { loadConfig } from './config.js'; +import { createClient } from './chain.js'; +import { createMetricsServer } from './metrics/server.js'; +import { watcherInfo, watcherLastPollTimestamp, watcherPollErrorsTotal, vaultContractsInfo } from './metrics/definitions.js'; + +const { version: WATCHER_VERSION } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')); +import { resolvePoolAddresses, pollVaults } from './monitors/vaultMonitor.js'; +import { + setWebhookIdentity, + sendInactiveEthAlert, + sendUnfinalizedRequestsAlert, + sendHealthWarningAlert, + sendHealthCriticalAlert, + sendForcedRebalanceAlert, + sendUtilizationHighAlert, + // sendReportStaleAlert, + sendVaultUnhealthyAlert, +} from './notifications/discord.js'; +import { hasUnfinalizedRequests } from './monitors/withdrawalMonitor.js'; +import { isInactiveEthAboveThreshold } from './monitors/efficiencyMonitor.js'; + +const MAX_UINT256 = 2n ** 256n - 1n; + +function info(msg) { + console.log(`INFO ${msg}`); +} + +function warn(vaultName, msg) { + console.log(`WARNING ⚠️ [${vaultName}] ${msg}`); +} + +function crit(vaultName, msg) { + console.log(`ERROR 🔴 [${vaultName}] ${msg}`); +} + +async function runPoll(config, client, vaultConfigs) { + const chain = config.chain; + try { + const snapshots = await pollVaults( + client, + config, + vaultConfigs, + config.stEthAddress, + config.vaultHubAddress + ); + watcherLastPollTimestamp.set({ chain }, Date.now() / 1000); + for (const s of snapshots) { + const pendingWd = Number(s.unfinalizedRequests); + const wdPart = pendingWd > 0 ? ` · 🕐 wd_pending=${pendingWd}` : ''; + info(`🔄 [${s.vault_name}] Poll OK · chain=${chain} · health=${s.healthFactorPct.toFixed(1)}% · stETH=${s.utilizationPct.toFixed(1)}%${wdPart}`); + } + + for (const s of snapshots) { + if (isInactiveEthAboveThreshold(s.inactiveEthWei, config.inactiveEthThresholdWei)) { + const eth = (Number(s.inactiveEthWei) / 1e18).toFixed(4); + warn(s.vault_name, `Inactive ETH · ${eth} ETH sitting idle (not in validators)`); + } + if (hasUnfinalizedRequests(s.unfinalizedRequests)) { + const count = Number(s.unfinalizedRequests); + const need = (Number(s.unfinalizedAssets) / 1e18).toFixed(4); + const have = (Number(s.availableBalance) / 1e18).toFixed(4); + const deficit = Number(s.unfinalizedAssets) / 1e18 - Number(s.availableBalance) / 1e18; + const deficitPart = deficit > 0 ? ` · ❗ exit ${deficit.toFixed(4)} ETH from validators` : ''; + warn(s.vault_name, `Withdrawal requests pending · ${count} req · ${need} ETH needed · ${have} ETH available${deficitPart}`); + } + if (s.healthFactorPct < config.healthCriticalThreshold) { + crit(s.vault_name, `Health factor critical · ${s.healthFactorPct.toFixed(1)}% (below ${config.healthCriticalThreshold}% threshold)`); + } else if (s.healthFactorPct < config.healthWarningThreshold) { + warn(s.vault_name, `Health factor low · ${s.healthFactorPct.toFixed(1)}% (below ${config.healthWarningThreshold}% threshold)`); + } + if (s.healthShortfallShares > 0n && s.healthShortfallShares !== MAX_UINT256) { + crit(s.vault_name, `Forced rebalance required · shortfall: ${s.healthShortfallShares} shares`); + } + if (s.utilizationPct >= config.utilizationWarningThreshold) { + warn(s.vault_name, `Utilization high · ${s.utilizationPct.toFixed(1)}% (above ${config.utilizationWarningThreshold}% threshold)`); + } + + if (!s.isHealthy) { + crit(s.vault_name, 'Vault unhealthy · check health factor and consider rebalancing'); + } + + if (config.discordWebhookUrl) { + const cooldown = config.alertCooldownMs; + const url = config.discordWebhookUrl; + const discordErr = (vaultName, type) => (e) => + console.log(`ERROR 🔴 [${vaultName}] Discord ${type} notification failed: ${e?.message ?? e}`); + + if (isInactiveEthAboveThreshold(s.inactiveEthWei, config.inactiveEthThresholdWei)) { + await sendInactiveEthAlert(url, cooldown, s.vault_name, s.vault, s.inactiveEthWei) + .catch(discordErr(s.vault_name, 'inactive-eth')); + } + if (hasUnfinalizedRequests(s.unfinalizedRequests)) { + await sendUnfinalizedRequestsAlert( + url, cooldown, s.vault_name, s.vault, + Number(s.unfinalizedRequests), s.unfinalizedAssets, s.availableBalance + ).catch(discordErr(s.vault_name, 'unfinalized-requests')); + } + if (s.healthFactorPct < config.healthCriticalThreshold) { + await sendHealthCriticalAlert(url, cooldown, s.vault_name, s.vault, s.healthFactorPct, config.healthCriticalThreshold) + .catch(discordErr(s.vault_name, 'health-critical')); + } else if (s.healthFactorPct < config.healthWarningThreshold) { + await sendHealthWarningAlert(url, cooldown, s.vault_name, s.vault, s.healthFactorPct, config.healthWarningThreshold) + .catch(discordErr(s.vault_name, 'health-warning')); + } + if (s.healthShortfallShares > 0n && s.healthShortfallShares !== MAX_UINT256) { + await sendForcedRebalanceAlert(url, cooldown, s.vault_name, s.vault, s.healthShortfallShares) + .catch(discordErr(s.vault_name, 'forced-rebalance')); + } + if (s.utilizationPct >= config.utilizationWarningThreshold) { + await sendUtilizationHighAlert(url, cooldown, s.vault_name, s.vault, s.utilizationPct, config.utilizationWarningThreshold) + .catch(discordErr(s.vault_name, 'utilization-high')); + } + if (!s.isHealthy) { + await sendVaultUnhealthyAlert(url, cooldown, s.vault_name, s.vault) + .catch(discordErr(s.vault_name, 'vault-unhealthy')); + } + } + } + } catch (err) { + watcherPollErrorsTotal.inc({ chain: config.chain }); + throw err; + } +} + +async function main() { + const config = loadConfig(); + info(`🚀 Starting stvaults-watcher v${WATCHER_VERSION} · chain=${config.chain} · vaults=${config.vaults.length}`); + watcherInfo.set({ version: WATCHER_VERSION, chain: config.chain, explorer_url: config.explorerUrl }, 1); + setWebhookIdentity({ username: config.discordUsername, avatarUrl: config.discordAvatarUrl }); + const client = createClient(config.rpcUrl, config.chainId); + + const vaultConfigs = []; + for (const vc of config.vaults) { + let resolved = { ...vc }; + if (vc.pool) { + info(`📡 [${vc.vault_name}] Resolving pool addresses...`); + const { withdrawalQueue, dashboard } = await resolvePoolAddresses(client, vc.pool, vc); + resolved.withdrawalQueue = vc.withdrawalQueue || withdrawalQueue; + resolved.dashboard = vc.dashboard || dashboard; + } + vaultConfigs.push(resolved); + vaultContractsInfo.set({ + vault_name: resolved.vault_name, + chain: config.chain, + vault_addr: resolved.vault, + pool_addr: resolved.pool || '', + wq_addr: resolved.withdrawalQueue || '', + dashboard_addr: resolved.dashboard || '', + }, 1); + } + + const metricsServer = createMetricsServer(config.metricsPort); + info(`📊 Metrics on :${config.metricsPort} · polling every ${config.pollIntervalMs / 60_000}min`); + + let pollTimer; + const schedule = () => { + runPoll(config, client, vaultConfigs).catch((err) => { + console.log(`ERROR 🔴 Poll error: ${err?.message ?? err}`); + }); + pollTimer = setTimeout(schedule, config.pollIntervalMs); + }; + schedule(); + + const shutdown = () => { + if (pollTimer) clearTimeout(pollTimer); + metricsServer.close(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((err) => { + console.log(`ERROR 🔴 Fatal: ${err?.message ?? err}`); + process.exit(1); +}); diff --git a/src/metrics/definitions.js b/src/metrics/definitions.js new file mode 100644 index 0000000..1dd2c40 --- /dev/null +++ b/src/metrics/definitions.js @@ -0,0 +1,234 @@ +import { Registry, Gauge, Counter } from 'prom-client'; + +const defaultLabels = ['vault', 'vault_name', 'chain']; + +export const register = new Registry(); + +// Vault - general state (values in ETH) +export const vaultTotalValueEth = new Gauge({ + name: 'lido_vault_total_value_eth', + help: 'Total value of the vault in ETH', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultAvailableBalanceEth = new Gauge({ + name: 'lido_vault_available_balance_eth', + help: 'ETH available in vault buffer', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultStagedBalanceEth = new Gauge({ + name: 'lido_vault_staged_balance_eth', + help: 'ETH staged for validators', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultInactiveEth = new Gauge({ + name: 'lido_vault_inactive_eth', + help: 'Inefficient ETH not in validators (available minus staged)', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultWithdrawableValueEth = new Gauge({ + name: 'lido_vault_withdrawable_value_eth', + help: 'ETH withdrawable from vault', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultNodeOperatorFeeEth = new Gauge({ + name: 'lido_vault_node_operator_fee_eth', + help: 'Undisbursed node operator fee in ETH (Dashboard.accruedFee)', + labelNames: defaultLabels, + registers: [register], +}); + +// Vault - PDG (PredepositGuarantee) +export const vaultPdgTotalEth = new Gauge({ + name: 'lido_vault_pdg_total_eth', + help: 'Total PDG guarantee balance for the vault node operator in ETH', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultPdgLockedEth = new Gauge({ + name: 'lido_vault_pdg_locked_eth', + help: 'Locked PDG guarantee balance in ETH', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultPdgUnlockedEth = new Gauge({ + name: 'lido_vault_pdg_unlocked_eth', + help: 'Unlocked PDG guarantee balance in ETH', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultPdgPendingActivations = new Gauge({ + name: 'lido_vault_pdg_pending_activations', + help: 'PDG validators pending activation for this vault', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultPdgPolicy = new Gauge({ + name: 'lido_vault_pdg_policy', + help: 'PDG policy enum from Dashboard (0=STRICT, 1=ALLOW_PROVE, 2=ALLOW_DEPOSIT_AND_PROVE)', + labelNames: defaultLabels, + registers: [register], +}); + +// Vault - health +export const vaultHealthFactor = new Gauge({ + name: 'lido_vault_health_factor', + help: 'Health factor (percentage)', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultIsHealthy = new Gauge({ + name: 'lido_vault_is_healthy', + help: '1 if vault is healthy, 0 otherwise', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultHealthShortfallShares = new Gauge({ + name: 'lido_vault_health_shortfall_shares', + help: 'Shares needed to restore vault health', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultUtilizationRatio = new Gauge({ + name: 'lido_vault_utilization_ratio', + help: 'Utilization ratio (percentage)', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultReportFresh = new Gauge({ + name: 'lido_vault_report_fresh', + help: '1 if oracle report is fresh, 0 if stale', + labelNames: defaultLabels, + registers: [register], +}); + +// Vault - connection params (from VaultHub.vaultConnection) +export const vaultForcedRebalanceThreshold = new Gauge({ + name: 'lido_vault_forced_rebalance_threshold', + help: 'Forced rebalance threshold (percentage, e.g. 50 = 50%)', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultReserveRatio = new Gauge({ + name: 'lido_vault_reserve_ratio', + help: 'Reserve ratio (percentage, e.g. 2.5 = 2.5%)', + labelNames: defaultLabels, + registers: [register], +}); + +// Vault - stETH +export const vaultStethLiabilityShares = new Gauge({ + name: 'lido_vault_steth_liability_shares', + help: 'stETH liability in shares', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultStethLiabilityEth = new Gauge({ + name: 'lido_vault_steth_liability_eth', + help: 'stETH liability in ETH', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultMintingCapacityShares = new Gauge({ + name: 'lido_vault_minting_capacity_shares', + help: 'Total stETH minting capacity in shares', + labelNames: defaultLabels, + registers: [register], +}); + +export const vaultMintingCapacityEth = new Gauge({ + name: 'lido_vault_minting_capacity_eth', + help: 'Total stETH minting capacity in ETH', + labelNames: defaultLabels, + registers: [register], +}); + +// Withdrawal queue +export const wqUnfinalizedRequests = new Gauge({ + name: 'lido_wq_unfinalized_requests', + help: 'Number of unfinalized withdrawal requests', + labelNames: defaultLabels, + registers: [register], +}); + +export const wqUnfinalizedAssetsEth = new Gauge({ + name: 'lido_wq_unfinalized_assets_eth', + help: 'ETH pending in withdrawal queue', + labelNames: defaultLabels, + registers: [register], +}); + +export const wqLastRequestId = new Gauge({ + name: 'lido_wq_last_request_id', + help: 'Last withdrawal request ID', + labelNames: defaultLabels, + registers: [register], +}); + +export const wqLastFinalizedId = new Gauge({ + name: 'lido_wq_last_finalized_id', + help: 'Last finalized withdrawal request ID', + labelNames: defaultLabels, + registers: [register], +}); + +// Vault contracts info (addresses as labels, set once at startup) +export const vaultContractsInfo = new Gauge({ + name: 'lido_vault_contracts_info', + help: 'Vault contract addresses (always 1). Labels carry addresses.', + labelNames: ['vault_name', 'chain', 'vault_addr', 'pool_addr', 'wq_addr', 'dashboard_addr'], + registers: [register], +}); + +// Watcher +export const watcherInfo = new Gauge({ + name: 'stvaults_watcher_info', + help: 'Watcher metadata (always 1). Labels carry version, chain and explorer_url.', + labelNames: ['version', 'chain', 'explorer_url'], + registers: [register], +}); + +export const watcherLastPollTimestamp = new Gauge({ + name: 'stvaults_watcher_last_poll_timestamp', + help: 'Timestamp of last successful poll', + labelNames: ['chain'], + registers: [register], +}); + +export const watcherPollErrorsTotal = new Counter({ + name: 'stvaults_watcher_poll_errors_total', + help: 'Total polling errors', + labelNames: ['chain'], + registers: [register], +}); + +/** + * Get label set for a vault. + * @param {string} vault - Vault address + * @param {string} vaultName - Human-readable vault id + * @param {string} chain - Chain name (e.g. mainnet, hoodi) + * @returns {{ vault: string, vault_name: string, chain: string }} + */ +export function vaultLabels(vault, vaultName, chain) { + return { vault, vault_name: vaultName, chain }; +} diff --git a/src/metrics/server.js b/src/metrics/server.js new file mode 100644 index 0000000..76d163c --- /dev/null +++ b/src/metrics/server.js @@ -0,0 +1,31 @@ +import http from 'node:http'; +import { register } from './definitions.js'; + +/** + * Create HTTP server that serves Prometheus metrics at GET /metrics. + * @param {number} port + * @returns {import('http').Server} + */ +export function createMetricsServer(port) { + const server = http.createServer(async (req, res) => { + if (req.method === 'GET' && req.url === '/metrics') { + try { + const metrics = await register.metrics(); + res.setHeader('Content-Type', register.contentType); + res.end(metrics); + } catch (err) { + res.statusCode = 500; + res.end(String(err)); + } + return; + } + res.statusCode = 404; + res.end('Not Found'); + }); + + server.listen(port, () => { + console.log('INFO Metrics server listening on port', port); + }); + + return server; +} diff --git a/src/monitors/efficiencyMonitor.js b/src/monitors/efficiencyMonitor.js new file mode 100644 index 0000000..2be3e8e --- /dev/null +++ b/src/monitors/efficiencyMonitor.js @@ -0,0 +1,24 @@ +/** + * Detection of inefficient ETH (available in vault but not in validators/staged). + */ + +/** + * Compute inactive ETH: available balance minus staged (ETH sitting in buffer not yet in validators). + * @param {bigint} availableBalanceWei + * @param {bigint} stagedBalanceWei + * @returns {bigint} + */ +export function computeInactiveEthWei(availableBalanceWei, stagedBalanceWei) { + if (availableBalanceWei <= stagedBalanceWei) return 0n; + return availableBalanceWei - stagedBalanceWei; +} + +/** + * Check if inactive ETH exceeds threshold (alert condition). + * @param {bigint} inactiveEthWei + * @param {bigint} thresholdWei + * @returns {boolean} + */ +export function isInactiveEthAboveThreshold(inactiveEthWei, thresholdWei) { + return inactiveEthWei > thresholdWei; +} diff --git a/src/monitors/healthMonitor.js b/src/monitors/healthMonitor.js new file mode 100644 index 0000000..3f841b1 --- /dev/null +++ b/src/monitors/healthMonitor.js @@ -0,0 +1,33 @@ +/** + * Health factor and utilization ratio computation from vault/VaultHub data. + */ + +/** + * Compute health factor as percentage. + * Formula: HF = Total Value × (1 - FRT) / Minted stETH (liability in ETH) + * @param {bigint} totalValueWei + * @param {number} forcedRebalanceThresholdBP - e.g. 1000 = 10% + * @param {bigint} liabilityEthWei - stETH liability converted to ETH + * @returns {number} Health factor percentage (e.g. 150 = 150%) + */ +export function computeHealthFactorPct(totalValueWei, forcedRebalanceThresholdBP, liabilityEthWei) { + if (liabilityEthWei === 0n) return Infinity; + const frt = Number(forcedRebalanceThresholdBP) / 10_000; + const numerator = Number(totalValueWei) * (1 - frt); + const pct = (numerator / Number(liabilityEthWei)) * 100; + const clamped = Math.max(0, pct); + // Keep the metric stable/noisy-less: round at source (not in Grafana). + return Math.round(clamped * 100) / 100; +} + +/** + * Compute utilization ratio as percentage. + * UR = (liabilityShares / totalMintingCapacityShares) × 100 + * @param {bigint} liabilityShares + * @param {bigint} mintingCapacityShares + * @returns {number} + */ +export function computeUtilizationRatioPct(liabilityShares, mintingCapacityShares) { + if (mintingCapacityShares === 0n) return 0; + return Number((liabilityShares * 10000n) / mintingCapacityShares) / 100; +} diff --git a/src/monitors/vaultMonitor.js b/src/monitors/vaultMonitor.js new file mode 100644 index 0000000..fc4a23f --- /dev/null +++ b/src/monitors/vaultMonitor.js @@ -0,0 +1,382 @@ +import { + vaultHubAbi, + vaultConnectionAbi, + stakingVaultAbi, + withdrawalQueueAbi, + poolAbi, + dashboardAbi, + stEthAbi, + pdgAbi, +} from '../abis.js'; +import { computeHealthFactorPct, computeUtilizationRatioPct } from './healthMonitor.js'; +import { computeInactiveEthWei } from './efficiencyMonitor.js'; +import { + vaultTotalValueEth, + vaultAvailableBalanceEth, + vaultStagedBalanceEth, + vaultInactiveEth, + vaultWithdrawableValueEth, + vaultNodeOperatorFeeEth, + vaultPdgTotalEth, + vaultPdgLockedEth, + vaultPdgUnlockedEth, + vaultPdgPendingActivations, + vaultPdgPolicy, + vaultHealthFactor, + vaultIsHealthy, + vaultHealthShortfallShares, + vaultUtilizationRatio, + vaultReportFresh, + vaultForcedRebalanceThreshold, + vaultReserveRatio, + vaultStethLiabilityShares, + vaultStethLiabilityEth, + vaultMintingCapacityShares, + vaultMintingCapacityEth, + wqUnfinalizedRequests, + wqUnfinalizedAssetsEth, + wqLastRequestId, + wqLastFinalizedId, + vaultLabels, +} from '../metrics/definitions.js'; + +const WEI_PER_ETH = 1e18; + +function weiToEth(wei) { + return Number(wei) / WEI_PER_ETH; +} + +const MAX_UINT256 = 2n ** 256n - 1n; + +/** + * Resolve withdrawalQueue and dashboard from pool if not set. + * @param {import('viem').PublicClient} client + * @param {string} poolAddress + * @param {{ vault: string, pool: string, vault_name: string, withdrawalQueue: string, dashboard: string }} vaultConfig + * @returns {Promise<{ withdrawalQueue: string, dashboard: string }>} + */ +export async function resolvePoolAddresses(client, poolAddress, vaultConfig) { + if (vaultConfig.withdrawalQueue && vaultConfig.dashboard) { + return { withdrawalQueue: vaultConfig.withdrawalQueue, dashboard: vaultConfig.dashboard }; + } + const [wq, dash] = await Promise.all([ + client.readContract({ + address: poolAddress, + abi: poolAbi, + functionName: 'WITHDRAWAL_QUEUE', + }), + client.readContract({ + address: poolAddress, + abi: poolAbi, + functionName: 'DASHBOARD', + }), + ]); + return { withdrawalQueue: wq, dashboard: dash }; +} + +/** + * Fetch all vault data and update metrics. Returns snapshot per vault for alerting. + * @param {import('viem').PublicClient} client + * @param {import('../config.js').Config} config + * @param {Array<{ vault: string, pool: string, vault_name: string, withdrawalQueue: string, dashboard: string }>} vaultConfigs - with withdrawalQueue/dashboard resolved + * @param {string} stEthAddress + * @param {string} vaultHubAddress + */ +export async function pollVaults(client, config, vaultConfigs, stEthAddress, vaultHubAddress) { + const chain = config.chain; + const pdgAddress = config.pdgAddress; + const results = []; + + for (const vc of vaultConfigs) { + const { vault, vault_name: vaultName, withdrawalQueue, dashboard } = vc; + const labels = vaultLabels(vault, vaultName, chain); + + try { + // VaultHub multicall + const [ + totalValue, + isHealthy, + healthShortfallShares, + liabilityShares, + mintingCapacityShares, + reportFresh, + withdrawableValue, + ] = await Promise.all([ + client.readContract({ + address: vaultHubAddress, + abi: vaultHubAbi, + functionName: 'totalValue', + args: [vault], + }), + client.readContract({ + address: vaultHubAddress, + abi: vaultHubAbi, + functionName: 'isVaultHealthy', + args: [vault], + }), + client.readContract({ + address: vaultHubAddress, + abi: vaultHubAbi, + functionName: 'healthShortfallShares', + args: [vault], + }), + client.readContract({ + address: vaultHubAddress, + abi: vaultHubAbi, + functionName: 'liabilityShares', + args: [vault], + }), + client.readContract({ + address: vaultHubAddress, + abi: vaultHubAbi, + functionName: 'totalMintingCapacityShares', + args: [vault, 0n], + }), + client.readContract({ + address: vaultHubAddress, + abi: vaultHubAbi, + functionName: 'isReportFresh', + args: [vault], + }), + client.readContract({ + address: vaultHubAddress, + abi: vaultHubAbi, + functionName: 'withdrawableValue', + args: [vault], + }), + ]); + + // VaultConnection (read real thresholds from contract) + let forcedRebalanceThresholdBP = config.forcedRebalanceThresholdBP ?? 1000; + let reserveRatioBP = 0; + try { + const conn = await client.readContract({ + address: vaultHubAddress, + abi: vaultConnectionAbi, + functionName: 'vaultConnection', + args: [vault], + }); + if (conn.forcedRebalanceThresholdBP > 0) { + forcedRebalanceThresholdBP = conn.forcedRebalanceThresholdBP; + } + reserveRatioBP = conn.reserveRatioBP ?? 0; + } catch (e) { + console.warn(`vaultConnection read failed for ${vaultName}, using config fallback (${forcedRebalanceThresholdBP}BP):`, e?.shortMessage ?? e?.message); + } + + // StakingVault + const [availableBalance, stagedBalance, nodeOperator] = await Promise.all([ + client.readContract({ + address: vault, + abi: stakingVaultAbi, + functionName: 'availableBalance', + }), + client.readContract({ + address: vault, + abi: stakingVaultAbi, + functionName: 'stagedBalance', + }), + client.readContract({ + address: vault, + abi: stakingVaultAbi, + functionName: 'nodeOperator', + }), + ]); + + // Convert shares to ETH via stETH + let liabilityEthWei = 0n; + let mintingCapacityEthWei = 0n; + if (stEthAddress) { + const sharesToConvert = []; + if (liabilityShares > 0n) sharesToConvert.push(liabilityShares); + if (mintingCapacityShares > 0n) sharesToConvert.push(mintingCapacityShares); + + if (sharesToConvert.length > 0) { + const results = await Promise.all( + sharesToConvert.map((s) => + client.readContract({ + address: stEthAddress, + abi: stEthAbi, + functionName: 'getPooledEthByShares', + args: [s], + }) + ) + ); + let idx = 0; + if (liabilityShares > 0n) liabilityEthWei = results[idx++]; + if (mintingCapacityShares > 0n) mintingCapacityEthWei = results[idx++]; + } + } + + const healthFactorPct = computeHealthFactorPct(totalValue, forcedRebalanceThresholdBP, liabilityEthWei); + const utilizationPct = computeUtilizationRatioPct(liabilityShares, mintingCapacityShares); + const inactiveEthWei = computeInactiveEthWei(availableBalance, stagedBalance); + + // Withdrawal queue (only if we have address). May revert when queue is empty/never used. + let unfinalizedRequests = 0n; + let unfinalizedAssets = 0n; + let lastRequestId = 0n; + let lastFinalizedId = 0n; + if (withdrawalQueue) { + try { + [unfinalizedRequests, unfinalizedAssets, lastRequestId, lastFinalizedId] = await Promise.all([ + client.readContract({ + address: withdrawalQueue, + abi: withdrawalQueueAbi, + functionName: 'unfinalizedRequestsNumber', + }), + client.readContract({ + address: withdrawalQueue, + abi: withdrawalQueueAbi, + functionName: 'unfinalizedAssets', + }), + client.readContract({ + address: withdrawalQueue, + abi: withdrawalQueueAbi, + functionName: 'getLastRequestId', + }), + client.readContract({ + address: withdrawalQueue, + abi: withdrawalQueueAbi, + functionName: 'getLastFinalizedRequestId', + }), + ]); + } catch (wqErr) { + // Contract can revert when queue has no requests or different interface; use zeros + console.warn( + `WithdrawalQueue read failed for ${vaultName} (${withdrawalQueue}), using zeros:`, + wqErr?.shortMessage ?? wqErr?.message ?? wqErr + ); + } + } + + let nodeOperatorFeeWei = 0n; + let pdgPolicy = 0; + if (dashboard) { + try { + [nodeOperatorFeeWei, pdgPolicy] = await Promise.all([ + client.readContract({ + address: dashboard, + abi: dashboardAbi, + functionName: 'accruedFee', + }), + client.readContract({ + address: dashboard, + abi: dashboardAbi, + functionName: 'pdgPolicy', + }), + ]); + } catch (feeErr) { + console.warn( + `dashboard read failed for ${vaultName} (${dashboard}), using defaults:`, + feeErr?.shortMessage ?? feeErr?.message ?? feeErr + ); + } + } + + let pdgTotalWei = 0n; + let pdgLockedWei = 0n; + let pdgUnlockedWei = 0n; + let pdgPendingActivations = 0n; + const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + if (nodeOperator === ZERO_ADDRESS) { + console.log(`WARN PDG reads skipped for ${vaultName}: nodeOperator() returned zero address`); + } else if (pdgAddress) { + try { + const [nodeOperatorBalance, unlockedBalance, pendingActivations] = await Promise.all([ + client.readContract({ + address: pdgAddress, + abi: pdgAbi, + functionName: 'nodeOperatorBalance', + args: [nodeOperator], + }), + client.readContract({ + address: pdgAddress, + abi: pdgAbi, + functionName: 'unlockedBalance', + args: [nodeOperator], + }), + client.readContract({ + address: pdgAddress, + abi: pdgAbi, + functionName: 'pendingActivations', + args: [vault], + }), + ]); + + [pdgTotalWei, pdgLockedWei] = nodeOperatorBalance; + pdgUnlockedWei = unlockedBalance; + pdgPendingActivations = pendingActivations; + } catch (pdgErr) { + console.warn( + `PDG read failed for ${vaultName} (${pdgAddress}), using zeros:`, + pdgErr?.shortMessage ?? pdgErr?.message ?? pdgErr + ); + } + } + + // Update metrics (ETH values for human readability) + vaultTotalValueEth.set(labels, weiToEth(totalValue)); + vaultAvailableBalanceEth.set(labels, weiToEth(availableBalance)); + vaultStagedBalanceEth.set(labels, weiToEth(stagedBalance)); + vaultInactiveEth.set(labels, weiToEth(inactiveEthWei)); + vaultWithdrawableValueEth.set(labels, weiToEth(withdrawableValue)); + vaultNodeOperatorFeeEth.set(labels, weiToEth(nodeOperatorFeeWei)); + vaultPdgTotalEth.set(labels, weiToEth(pdgTotalWei)); + vaultPdgLockedEth.set(labels, weiToEth(pdgLockedWei)); + vaultPdgUnlockedEth.set(labels, weiToEth(pdgUnlockedWei)); + vaultPdgPendingActivations.set(labels, Number(pdgPendingActivations)); + vaultPdgPolicy.set(labels, Number(pdgPolicy)); + vaultHealthFactor.set(labels, healthFactorPct); + vaultIsHealthy.set(labels, isHealthy ? 1 : 0); + vaultHealthShortfallShares.set(labels, healthShortfallShares === MAX_UINT256 ? 0 : Number(healthShortfallShares)); + vaultUtilizationRatio.set(labels, utilizationPct); + vaultReportFresh.set(labels, reportFresh ? 1 : 0); + vaultForcedRebalanceThreshold.set(labels, forcedRebalanceThresholdBP / 100); + vaultReserveRatio.set(labels, reserveRatioBP / 100); + vaultStethLiabilityShares.set(labels, Number(liabilityShares)); + vaultStethLiabilityEth.set(labels, weiToEth(liabilityEthWei)); + vaultMintingCapacityShares.set(labels, Number(mintingCapacityShares)); + vaultMintingCapacityEth.set(labels, weiToEth(mintingCapacityEthWei)); + wqUnfinalizedRequests.set(labels, Number(unfinalizedRequests)); + wqUnfinalizedAssetsEth.set(labels, weiToEth(unfinalizedAssets)); + wqLastRequestId.set(labels, Number(lastRequestId)); + wqLastFinalizedId.set(labels, Number(lastFinalizedId)); + + results.push({ + vault, + vault_name: vaultName, + chain, + totalValue, + availableBalance, + stagedBalance, + inactiveEthWei, + withdrawableValue, + nodeOperatorFeeWei, + nodeOperator, + pdgTotalWei, + pdgLockedWei, + pdgUnlockedWei, + pdgPendingActivations, + pdgPolicy, + isHealthy, + healthShortfallShares, + healthFactorPct, + utilizationPct, + reportFresh, + liabilityShares, + liabilityEthWei, + mintingCapacityShares, + unfinalizedRequests, + unfinalizedAssets, + lastRequestId, + lastFinalizedId, + }); + } catch (err) { + console.error(`Poll error for vault ${vault} (${vaultName}):`, err); + throw err; + } + } + + return results; +} diff --git a/src/monitors/withdrawalMonitor.js b/src/monitors/withdrawalMonitor.js new file mode 100644 index 0000000..d33f558 --- /dev/null +++ b/src/monitors/withdrawalMonitor.js @@ -0,0 +1,12 @@ +/** + * Withdrawal queue state for DeFi Wrapper - used for metrics and alert conditions. + */ + +/** + * Check if there are unfinalized withdrawal requests. + * @param {bigint} unfinalizedRequestsNumber + * @returns {boolean} + */ +export function hasUnfinalizedRequests(unfinalizedRequestsNumber) { + return unfinalizedRequestsNumber > 0n; +} diff --git a/src/networks.json b/src/networks.json new file mode 100644 index 0000000..5b89a09 --- /dev/null +++ b/src/networks.json @@ -0,0 +1,20 @@ +{ + "mainnet": { + "chainId": 1, + "explorerUrl": "https://etherscan.io", + "contracts": { + "vaultHub": "0x1d201BE093d847f6446530Efb0E8Fb426d176709", + "stEth": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", + "pdg": "0xF4bF42c6D6A0E38825785048124DBAD6c9eaaac3" + } + }, + "hoodi": { + "chainId": 560048, + "explorerUrl": "https://hoodi.etherscan.io", + "contracts": { + "vaultHub": "0x4C9fFC325392090F789255b9948Ab1659b797964", + "stEth": "0x3508A952176b3c15387C97BE809eaffB1982176a", + "pdg": "0xa5F55f3402beA2B14AE15Dae1b6811457D43581d" + } + } +} diff --git a/src/notifications/discord.js b/src/notifications/discord.js new file mode 100644 index 0000000..d8a3868 --- /dev/null +++ b/src/notifications/discord.js @@ -0,0 +1,191 @@ +/** + * Discord webhook alerts with cooldown. No recovery messages. + */ + +const COLORS = { warning: 0xf1c532, critical: 0xe74c3c }; + +const cooldowns = new Map(); + +/** + * @param {string} alertKey - Unique key per alert type + vault (e.g. "inactive-eth:0x...") + * @param {number} cooldownMs + * @returns {boolean} true if we should send (not in cooldown) + */ +export function shouldSendAlert(alertKey, cooldownMs) { + const now = Date.now(); + const last = cooldowns.get(alertKey) ?? 0; + if (now - last < cooldownMs) return false; + cooldowns.set(alertKey, now); + return true; +} + +let webhookIdentity = {}; + +/** + * Set the Discord webhook username and avatar for all messages. + * @param {{ username?: string, avatarUrl?: string }} identity + */ +export function setWebhookIdentity(identity) { + if (identity.username) webhookIdentity.username = identity.username; + if (identity.avatarUrl) webhookIdentity.avatar_url = identity.avatarUrl; +} + +/** + * @param {string} url - Discord webhook URL + * @param {{ title: string, description?: string, color: number, fields?: Array<{ name: string, value: string, inline?: boolean }> }} embed + */ +export async function sendDiscordEmbed(url, embed) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...webhookIdentity, embeds: [embed] }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Discord webhook failed ${res.status}: ${text}`); + } +} + +/** + * Send alert for inactive ETH above threshold. + */ +export async function sendInactiveEthAlert(webhookUrl, cooldownMs, vaultName, vaultAddress, inactiveEthWei) { + const key = `inactive-eth:${vaultAddress}`; + if (!shouldSendAlert(key, cooldownMs)) return; + const eth = Number(inactiveEthWei) / 1e18; + await sendDiscordEmbed(webhookUrl, { + title: `⚠️ Inefficient ETH (${vaultName})`, + description: `Vault **${vaultName}** has ETH in the buffer that is not in validators.`, + color: COLORS.warning, + fields: [ + { name: 'Vault', value: vaultAddress, inline: false }, + { name: 'Inactive ETH', value: `${eth.toFixed(4)} ETH`, inline: true }, + ], + }); +} + +/** + * Send alert for new unfinalized withdrawal requests. + */ +export async function sendUnfinalizedRequestsAlert(webhookUrl, cooldownMs, vaultName, vaultAddress, count, unfinalizedAssetsWei, availableBalanceWei) { + const key = `unfinalized-requests:${vaultAddress}`; + if (!shouldSendAlert(key, cooldownMs)) return; + const need = Number(unfinalizedAssetsWei) / 1e18; + const have = Number(availableBalanceWei) / 1e18; + const deficit = need - have; + const covered = deficit <= 0; + const fields = [ + { name: 'Vault', value: vaultAddress, inline: false }, + { name: 'Requests', value: String(count), inline: true }, + { name: 'ETH pending', value: `${need.toFixed(4)} ETH`, inline: true }, + { name: 'Unstaked in vault', value: `${have.toFixed(4)} ETH`, inline: true }, + ]; + if (!covered) { + fields.push({ name: '⚠️ Exit from validators', value: `${deficit.toFixed(4)} ETH`, inline: true }); + } + await sendDiscordEmbed(webhookUrl, { + title: `⚠️ Withdrawal Requests Pending (${vaultName})`, + description: covered + ? `Vault **${vaultName}** has unfinalized withdrawal requests. The vault has enough balance to cover them.` + : `Vault **${vaultName}** has unfinalized withdrawal requests. The vault does **NOT** have enough balance. You need to exit **${deficit.toFixed(4)} ETH** from validators.`, + color: covered ? COLORS.warning : COLORS.critical, + fields, + }); +} + +/** + * Send alert for low health factor (warning threshold). + */ +export async function sendHealthWarningAlert(webhookUrl, cooldownMs, vaultName, vaultAddress, healthFactorPct, threshold) { + const key = `health-warning:${vaultAddress}`; + if (!shouldSendAlert(key, cooldownMs)) return; + await sendDiscordEmbed(webhookUrl, { + title: `⚠️ Health Factor Low (${vaultName})`, + description: `Vault **${vaultName}** health factor is below ${threshold}%.`, + color: COLORS.warning, + fields: [ + { name: 'Vault', value: vaultAddress, inline: false }, + { name: 'Health factor', value: `${healthFactorPct.toFixed(2)}%`, inline: true }, + { name: 'Threshold', value: `${threshold}%`, inline: true }, + ], + }); +} + +/** + * Send alert for critical health factor. + */ +export async function sendHealthCriticalAlert(webhookUrl, cooldownMs, vaultName, vaultAddress, healthFactorPct, threshold) { + const key = `health-critical:${vaultAddress}`; + if (!shouldSendAlert(key, cooldownMs)) return; + await sendDiscordEmbed(webhookUrl, { + title: `🔴 Health Factor Critical (${vaultName})`, + description: `Vault **${vaultName}** health factor is below ${threshold}%. Forced rebalance may apply.`, + color: COLORS.critical, + fields: [ + { name: 'Vault', value: vaultAddress, inline: false }, + { name: 'Health factor', value: `${healthFactorPct.toFixed(2)}%`, inline: true }, + ], + }); +} + +/** + * Send alert for forced rebalance (health shortfall > 0). + */ +export async function sendForcedRebalanceAlert(webhookUrl, cooldownMs, vaultName, vaultAddress, healthShortfallShares) { + const key = `forced-rebalance:${vaultAddress}`; + if (!shouldSendAlert(key, cooldownMs)) return; + await sendDiscordEmbed(webhookUrl, { + title: `🔴 Forced Rebalance Required (${vaultName})`, + description: `Vault **${vaultName}** has health shortfall. Rebalancing is required.`, + color: COLORS.critical, + fields: [ + { name: 'Vault', value: vaultAddress, inline: false }, + { name: 'Health shortfall (shares)', value: String(healthShortfallShares), inline: true }, + ], + }); +} + +/** + * Send alert for high utilization ratio. + */ +export async function sendUtilizationHighAlert(webhookUrl, cooldownMs, vaultName, vaultAddress, utilizationPct, threshold) { + const key = `utilization-high:${vaultAddress}`; + if (!shouldSendAlert(key, cooldownMs)) return; + await sendDiscordEmbed(webhookUrl, { + title: `⚠️ Utilization Ratio High (${vaultName})`, + description: `Vault **${vaultName}** utilization is above ${threshold}%.`, + color: COLORS.warning, + fields: [ + { name: 'Vault', value: vaultAddress, inline: false }, + { name: 'Utilization', value: `${utilizationPct.toFixed(2)}%`, inline: true }, + ], + }); +} + +/** + * Send alert for stale oracle report. + */ +export async function sendReportStaleAlert(webhookUrl, cooldownMs, vaultName, vaultAddress) { + const key = `report-stale:${vaultAddress}`; + if (!shouldSendAlert(key, cooldownMs)) return; + await sendDiscordEmbed(webhookUrl, { + title: `⚠️ Oracle Report Stale (${vaultName})`, + description: `Vault **${vaultName}** oracle report is no longer fresh. Submit a report if needed.`, + color: COLORS.warning, + fields: [{ name: 'Vault', value: vaultAddress, inline: false }], + }); +} + +/** + * Send alert for vault not healthy (isVaultHealthy false). + */ +export async function sendVaultUnhealthyAlert(webhookUrl, cooldownMs, vaultName, vaultAddress) { + const key = `vault-unhealthy:${vaultAddress}`; + if (!shouldSendAlert(key, cooldownMs)) return; + await sendDiscordEmbed(webhookUrl, { + title: `🔴 Vault Unhealthy (${vaultName})`, + description: `Vault **${vaultName}** is not healthy. Check health factor and consider rebalancing.`, + color: COLORS.critical, + fields: [{ name: 'Vault', value: vaultAddress, inline: false }], + }); +} diff --git a/static/avatar.png b/static/avatar.png new file mode 100644 index 0000000..3f513ae Binary files /dev/null and b/static/avatar.png differ diff --git a/static/header.png b/static/header.png new file mode 100644 index 0000000..59674ae Binary files /dev/null and b/static/header.png differ diff --git a/static/terminal.png b/static/terminal.png new file mode 100644 index 0000000..bdf1035 Binary files /dev/null and b/static/terminal.png differ diff --git a/tests/abis.test.js b/tests/abis.test.js new file mode 100644 index 0000000..c775fe7 --- /dev/null +++ b/tests/abis.test.js @@ -0,0 +1,25 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + vaultHubAbi, + vaultConnectionAbi, + stakingVaultAbi, + withdrawalQueueAbi, + poolAbi, + stEthAbi, +} from "../src/abis.js"; + +test("all ABI exports are non-empty arrays", () => { + for (const abi of [vaultHubAbi, vaultConnectionAbi, stakingVaultAbi, withdrawalQueueAbi, poolAbi, stEthAbi]) { + assert.equal(Array.isArray(abi), true); + assert.equal(abi.length > 0, true); + } +}); + +test("vaultConnectionAbi contains tuple output with expected fields", () => { + const item = vaultConnectionAbi[0]; + assert.equal(item.name, "vaultConnection"); + assert.equal(item.outputs[0].type, "tuple"); + assert.equal(item.outputs[0].components.some((c) => c.name === "forcedRebalanceThresholdBP"), true); + assert.equal(item.outputs[0].components.some((c) => c.name === "reserveRatioBP"), true); +}); diff --git a/tests/config.test.js b/tests/config.test.js new file mode 100644 index 0000000..81e993d --- /dev/null +++ b/tests/config.test.js @@ -0,0 +1,134 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { loadConfig } from "../src/config.js"; + +const VALID_ADDR_A = "0x1111111111111111111111111111111111111111"; +const VALID_ADDR_B = "0x2222222222222222222222222222222222222222"; +const ORIGINAL_ENV = process.env; + +function baseEnv() { + return { + RPC_URL: "https://rpc.example", + CHAIN: "mainnet", + VAULT_CONFIGS: JSON.stringify([{ vault: VALID_ADDR_A, vault_name: "vault-a", pool: VALID_ADDR_B }]), + }; +} + +function setEnv(newEnv) { + process.env = { ...newEnv }; +} + +test.after(() => { + process.env = ORIGINAL_ENV; +}); + +test("loadConfig parses valid minimum configuration and defaults", () => { + setEnv(baseEnv()); + const cfg = loadConfig(); + + assert.equal(cfg.rpcUrl, "https://rpc.example"); + assert.equal(cfg.chainId, 1); + assert.equal(cfg.chain, "mainnet"); + assert.equal(cfg.metricsPort, 9600); + assert.equal(cfg.pollIntervalMs, 60_000); + assert.equal(cfg.alertCooldownMs, 30 * 60_000); + assert.equal(cfg.healthWarningThreshold, 107); + assert.equal(cfg.healthCriticalThreshold, 102); + assert.equal(cfg.vaults.length, 1); +}); + +test("loadConfig throws when required env var is missing", () => { + const env = baseEnv(); + delete env.RPC_URL; + setEnv(env); + assert.throws(() => loadConfig(), /Missing required env vars: RPC_URL/); +}); + +test("loadConfig throws when CHAIN is unsupported", () => { + const env = baseEnv(); + env.CHAIN = "sepolia"; + setEnv(env); + assert.throws(() => loadConfig(), /Unsupported CHAIN "sepolia"/); +}); + +test("loadConfig throws for invalid VAULT_CONFIGS JSON", () => { + const env = baseEnv(); + env.VAULT_CONFIGS = "{not-json"; + setEnv(env); + assert.throws(() => loadConfig(), /VAULT_CONFIGS must be a valid JSON array/); +}); + +test("loadConfig throws for empty VAULT_CONFIGS array", () => { + const env = baseEnv(); + env.VAULT_CONFIGS = "[]"; + setEnv(env); + assert.throws(() => loadConfig(), /VAULT_CONFIGS must be a non-empty JSON array/); +}); + +test("loadConfig throws for invalid vault address", () => { + const env = baseEnv(); + env.VAULT_CONFIGS = JSON.stringify([{ vault: "0x1234", vault_name: "x" }]); + setEnv(env); + assert.throws(() => loadConfig(), /vault must be a valid 0x40-hex address/); +}); + +test("loadConfig accepts vault without pool", () => { + const env = baseEnv(); + env.VAULT_CONFIGS = JSON.stringify([{ vault: VALID_ADDR_A, vault_name: "no-pool" }]); + setEnv(env); + const cfg = loadConfig(); + assert.equal(cfg.vaults[0].pool, ""); + assert.equal(cfg.vaults[0].vault_name, "no-pool"); +}); + +test("loadConfig ignores label key and falls back to generated vault_name", () => { + const env = baseEnv(); + env.VAULT_CONFIGS = JSON.stringify([{ vault: VALID_ADDR_A, label: "legacy-name", pool: VALID_ADDR_B }]); + setEnv(env); + const cfg = loadConfig(); + assert.equal(cfg.vaults[0].vault_name, "vault-0"); +}); + +test("loadConfig picks optional overrides from env", () => { + const env = baseEnv(); + env.POLL_INTERVAL_MIN = "2.5"; + env.METRICS_PORT = "9700"; + env.ALERT_COOLDOWN_MIN = "10"; + env.INACTIVE_ETH_THRESHOLD = "4200"; + env.HEALTH_WARNING_THRESHOLD = "130"; + env.HEALTH_CRITICAL_THRESHOLD = "110"; + env.UTILIZATION_WARNING_THRESHOLD = "96"; + env.FORCED_REBALANCE_THRESHOLD_BP = "900"; + setEnv(env); + + const cfg = loadConfig(); + assert.equal(cfg.pollIntervalMs, 150_000); + assert.equal(cfg.metricsPort, 9700); + assert.equal(cfg.alertCooldownMs, 600_000); + assert.equal(cfg.inactiveEthThresholdWei, 4200n * 10n ** 18n); + assert.equal(cfg.healthWarningThreshold, 130); + assert.equal(cfg.healthCriticalThreshold, 110); + assert.equal(cfg.utilizationWarningThreshold, 96); + assert.equal(cfg.forcedRebalanceThresholdBP, 900); +}); + +test("loadConfig throws for empty INACTIVE_ETH_THRESHOLD", () => { + const env = baseEnv(); + env.INACTIVE_ETH_THRESHOLD = ""; + setEnv(env); + assert.throws(() => loadConfig(), /INACTIVE_ETH_THRESHOLD must not be empty/); +}); + +test("loadConfig throws for invalid INACTIVE_ETH_THRESHOLD", () => { + const env = baseEnv(); + env.INACTIVE_ETH_THRESHOLD = "2..5"; + setEnv(env); + assert.throws(() => loadConfig(), /INACTIVE_ETH_THRESHOLD is invalid/); +}); + +test("loadConfig throws for scientific notation in INACTIVE_ETH_THRESHOLD", () => { + const env = baseEnv(); + env.INACTIVE_ETH_THRESHOLD = "32e18"; + setEnv(env); + assert.throws(() => loadConfig(), /INACTIVE_ETH_THRESHOLD is invalid/); +}); diff --git a/tests/grafana/build.test.ts b/tests/grafana/build.test.ts new file mode 100644 index 0000000..b00c508 --- /dev/null +++ b/tests/grafana/build.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +function readBuiltDashboard() { + const filePath = resolve("grafana", "dashboard.json"); + return JSON.parse(readFileSync(filePath, "utf8")); +} + +test("build script produces patched Contracts panel, explorer_base variable and logs syntax highlighting", async () => { + const buildPath = resolve("src", "grafana", "build.ts"); + await import(`${pathToFileURL(buildPath).href}?test=${Date.now()}-${Math.random()}`); + + const dashboard = readBuiltDashboard(); + const contracts = dashboard.panels.find((p: any) => p.title === "Contracts"); + assert.equal(contracts?.type, "table"); + + const vars = dashboard.templating?.list ?? []; + const explorerVar = vars.find((v: any) => v.name === "explorer_base"); + assert.equal(explorerVar?.type, "query"); + + const logs = dashboard.panels.find((p: any) => p.title === "Watcher logs"); + assert.equal(logs?.options?.syntaxHighlighting, true); + + // Verify that SDK-generated datasource variables are removed from templating.list + // (datasource selection is handled via __inputs at import time instead). + assert.equal(vars.find((v: any) => v.name === "DS_PROMETHEUS"), undefined, 'DS_PROMETHEUS must not appear in templating.list'); + assert.equal(vars.find((v: any) => v.name === "DS_LOKI"), undefined, 'DS_LOKI must not appear in templating.list'); + + // Verify that __inputs declares both datasource placeholders so Grafana prompts on import. + const inputs: any[] = dashboard.__inputs ?? []; + assert.equal(inputs.some((i: any) => i.name === "DS_PROMETHEUS" && i.pluginId === "prometheus"), true, 'missing DS_PROMETHEUS in __inputs'); + assert.equal(inputs.some((i: any) => i.name === "DS_LOKI" && i.pluginId === "loki"), true, 'missing DS_LOKI in __inputs'); +}); diff --git a/tests/grafana/dashboard.test.ts b/tests/grafana/dashboard.test.ts new file mode 100644 index 0000000..ece350a --- /dev/null +++ b/tests/grafana/dashboard.test.ts @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildDashboard } from "../../src/grafana/dashboard.ts"; + +function getPanels(dashboard: any) { + return dashboard.panels ?? []; +} + +test("buildDashboard returns base dashboard shape", () => { + const dashboard: any = buildDashboard(); + assert.equal(typeof dashboard.schemaVersion, "number"); + assert.equal(Array.isArray(dashboard.templating?.list), true); + assert.equal(Array.isArray(getPanels(dashboard)), true); + assert.equal(getPanels(dashboard).length > 0, true); +}); + +test("buildDashboard includes key template variables", () => { + const dashboard: any = buildDashboard(); + const vars = dashboard.templating.list.map((v: any) => v.name); + // datasource vars are named DS_PROMETHEUS / DS_LOKI (used as __inputs placeholders); + // build.ts removes them from templating.list, but dashboard.ts still emits them. + assert.equal(vars.includes("DS_PROMETHEUS"), true); + assert.equal(vars.includes("chain"), true); + assert.equal(vars.includes("vault_name"), true); + assert.equal(vars.includes("DS_LOKI"), true); +}); + +test("buildDashboard includes key panels by title", () => { + const dashboard: any = buildDashboard(); + const titles = getPanels(dashboard).map((p: any) => p.title); + for (const title of [ + "Watcher version", + "Health factor", + "Withdrawal deficit", + "Contracts", + "Watcher logs", + ]) { + assert.equal(titles.includes(title), true, `missing panel: ${title}`); + } +}); diff --git a/tests/grafana/panels.test.ts b/tests/grafana/panels.test.ts new file mode 100644 index 0000000..daf7bc6 --- /dev/null +++ b/tests/grafana/panels.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { DATASOURCE_VAR, gaugePanel, statPanel, timeseriesPanel } from "../../src/grafana/panels.ts"; + +test("DATASOURCE_VAR keeps expected variable name", () => { + assert.equal(DATASOURCE_VAR, "DS_PROMETHEUS"); +}); + +test("statPanel builds stat panel config with expression", () => { + const panel: any = statPanel({ + title: "StatTest", + expr: "up", + unit: "short", + }).build(); + + assert.equal(panel.type, "stat"); + assert.equal(panel.title, "StatTest"); + assert.equal(panel.targets[0].expr, "up"); +}); + +test("gaugePanel builds gauge panel config with expression", () => { + const panel: any = gaugePanel({ + title: "GaugeTest", + expr: "up", + min: 0, + max: 100, + }).build(); + + assert.equal(panel.type, "gauge"); + assert.equal(panel.title, "GaugeTest"); + assert.equal(panel.targets[0].expr, "up"); +}); + +test("timeseriesPanel builds timeseries panel config with expression", () => { + const panel: any = timeseriesPanel({ + title: "TimeseriesTest", + expr: "up", + }).build(); + + assert.equal(panel.type, "timeseries"); + assert.equal(panel.title, "TimeseriesTest"); + assert.equal(panel.targets[0].expr, "up"); +}); diff --git a/tests/metrics/definitions.test.js b/tests/metrics/definitions.test.js new file mode 100644 index 0000000..5694e9b --- /dev/null +++ b/tests/metrics/definitions.test.js @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + register, + vaultLabels, + vaultTotalValueEth, + watcherInfo, + vaultContractsInfo, +} from "../../src/metrics/definitions.js"; + +test("vaultLabels returns expected labels object", () => { + assert.deepEqual(vaultLabels("0xabc", "vault-a", "mainnet"), { + vault: "0xabc", + vault_name: "vault-a", + chain: "mainnet", + }); +}); + +test("register contains expected metric names", async () => { + const metrics = await register.getMetricsAsJSON(); + const names = new Set(metrics.map((m) => m.name)); + + for (const requiredName of [ + "lido_vault_total_value_eth", + "lido_vault_health_factor", + "lido_wq_unfinalized_requests", + "lido_vault_contracts_info", + "stvaults_watcher_info", + "stvaults_watcher_poll_errors_total", + ]) { + assert.equal(names.has(requiredName), true, `missing metric: ${requiredName}`); + } +}); + +test("key metric objects expose expected label sets", () => { + assert.deepEqual(vaultTotalValueEth.labelNames, ["vault", "vault_name", "chain"]); + assert.deepEqual(watcherInfo.labelNames, ["version", "chain", "explorer_url"]); + assert.deepEqual(vaultContractsInfo.labelNames, [ + "vault_name", + "chain", + "vault_addr", + "pool_addr", + "wq_addr", + "dashboard_addr", + ]); +}); diff --git a/tests/metrics/server.test.js b/tests/metrics/server.test.js new file mode 100644 index 0000000..f6aabdb --- /dev/null +++ b/tests/metrics/server.test.js @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createMetricsServer } from "../../src/metrics/server.js"; +import { register } from "../../src/metrics/definitions.js"; + +test("createMetricsServer serves /metrics and returns 404 for unknown path", async () => { + register.resetMetrics(); + const server = createMetricsServer(0); + await once(server, "listening"); + + const { port } = server.address(); + const metricsResponse = await fetch(`http://127.0.0.1:${port}/metrics`); + assert.equal(metricsResponse.status, 200); + assert.match(metricsResponse.headers.get("content-type"), /text\/plain/); + + const unknownResponse = await fetch(`http://127.0.0.1:${port}/unknown`); + assert.equal(unknownResponse.status, 404); + + await new Promise((resolve) => server.close(resolve)); +}); diff --git a/tests/monitors/efficiencyMonitor.test.js b/tests/monitors/efficiencyMonitor.test.js new file mode 100644 index 0000000..b1c2bd5 --- /dev/null +++ b/tests/monitors/efficiencyMonitor.test.js @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { computeInactiveEthWei, isInactiveEthAboveThreshold } from "../../src/monitors/efficiencyMonitor.js"; + +test("computeInactiveEthWei returns difference when available exceeds staged", () => { + assert.equal(computeInactiveEthWei(10n, 4n), 6n); +}); + +test("computeInactiveEthWei returns zero when available is lower", () => { + assert.equal(computeInactiveEthWei(4n, 10n), 0n); +}); + +test("computeInactiveEthWei returns zero when values are equal", () => { + assert.equal(computeInactiveEthWei(10n, 10n), 0n); +}); + +test("isInactiveEthAboveThreshold returns true only when strictly above threshold", () => { + assert.equal(isInactiveEthAboveThreshold(11n, 10n), true); + assert.equal(isInactiveEthAboveThreshold(10n, 10n), false); + assert.equal(isInactiveEthAboveThreshold(9n, 10n), false); +}); diff --git a/tests/monitors/healthMonitor.test.js b/tests/monitors/healthMonitor.test.js new file mode 100644 index 0000000..e3a2cef --- /dev/null +++ b/tests/monitors/healthMonitor.test.js @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { computeHealthFactorPct, computeUtilizationRatioPct } from "../../src/monitors/healthMonitor.js"; + +test("computeHealthFactorPct returns Infinity when liability is zero (no minted stETH)", () => { + const value = computeHealthFactorPct(10n * 10n ** 18n, 1000, 0n); + assert.equal(value, Infinity); +}); + +test("computeHealthFactorPct computes percentage and rounds to 2 decimals", () => { + const value = computeHealthFactorPct(200n * 10n ** 18n, 1000, 100n * 10n ** 18n); + assert.equal(value, 180); +}); + +test("computeHealthFactorPct clamps negative values to zero", () => { + const value = computeHealthFactorPct(0n, 1000, 100n * 10n ** 18n); + assert.equal(value, 0); +}); + +test("computeUtilizationRatioPct returns 0 when capacity is zero", () => { + const value = computeUtilizationRatioPct(25n, 0n); + assert.equal(value, 0); +}); + +test("computeUtilizationRatioPct returns expected percentage", () => { + const value = computeUtilizationRatioPct(25n, 100n); + assert.equal(value, 25); +}); + +test("computeUtilizationRatioPct returns 100 when fully utilized", () => { + const value = computeUtilizationRatioPct(100n, 100n); + assert.equal(value, 100); +}); diff --git a/tests/monitors/vaultMonitor.test.js b/tests/monitors/vaultMonitor.test.js new file mode 100644 index 0000000..441b4d1 --- /dev/null +++ b/tests/monitors/vaultMonitor.test.js @@ -0,0 +1,229 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { pollVaults, resolvePoolAddresses } from "../../src/monitors/vaultMonitor.js"; +import { register } from "../../src/metrics/definitions.js"; + +const ADDR_VAULT = "0x1111111111111111111111111111111111111111"; +const ADDR_POOL = "0x2222222222222222222222222222222222222222"; +const ADDR_WQ = "0x3333333333333333333333333333333333333333"; +const ADDR_DASH = "0x4444444444444444444444444444444444444444"; +const ADDR_STETH = "0x5555555555555555555555555555555555555555"; +const ADDR_HUB = "0x6666666666666666666666666666666666666666"; +const ADDR_PDG = "0x7777777777777777777777777777777777777777"; +const ADDR_NODE_OPERATOR = "0x8888888888888888888888888888888888888888"; + +test.beforeEach(() => { + register.resetMetrics(); +}); + +test("resolvePoolAddresses returns configured addresses without on-chain reads", async () => { + const client = { + async readContract() { + throw new Error("should not be called"); + }, + }; + + const result = await resolvePoolAddresses(client, ADDR_POOL, { + withdrawalQueue: ADDR_WQ, + dashboard: ADDR_DASH, + }); + + assert.deepEqual(result, { withdrawalQueue: ADDR_WQ, dashboard: ADDR_DASH }); +}); + +test("resolvePoolAddresses fetches addresses from pool when missing", async () => { + const calls = []; + const client = { + async readContract(req) { + calls.push(req.functionName); + if (req.functionName === "WITHDRAWAL_QUEUE") return ADDR_WQ; + if (req.functionName === "DASHBOARD") return ADDR_DASH; + throw new Error(`unexpected function: ${req.functionName}`); + }, + }; + + const result = await resolvePoolAddresses(client, ADDR_POOL, {}); + assert.deepEqual(result, { withdrawalQueue: ADDR_WQ, dashboard: ADDR_DASH }); + assert.deepEqual(calls.sort(), ["DASHBOARD", "WITHDRAWAL_QUEUE"]); +}); + +test("pollVaults returns snapshot and updates Prometheus metrics", async () => { + const readContract = async (req) => { + switch (req.functionName) { + case "totalValue": + return 200n * 10n ** 18n; + case "isVaultHealthy": + return true; + case "healthShortfallShares": + return 0n; + case "liabilityShares": + return 100n; + case "totalMintingCapacityShares": + return 200n; + case "isReportFresh": + return true; + case "withdrawableValue": + return 20n * 10n ** 18n; + case "vaultConnection": + return { forcedRebalanceThresholdBP: 1000, reserveRatioBP: 250 }; + case "availableBalance": + return 15n * 10n ** 18n; + case "stagedBalance": + return 3n * 10n ** 18n; + case "nodeOperator": + return ADDR_NODE_OPERATOR; + case "accruedFee": + return 29500000000000000n; + case "pdgPolicy": + return 2n; + case "getPooledEthByShares": + return req.args[0] * 10n ** 18n; + case "nodeOperatorBalance": + // viem returns multiple named outputs as a plain array at runtime, not a named object + return [5n * 10n ** 18n, 2n * 10n ** 18n]; + case "unlockedBalance": + return 3n * 10n ** 18n; + case "pendingActivations": + return 4n; + case "unfinalizedRequestsNumber": + return 2n; + case "unfinalizedAssets": + return 4n * 10n ** 18n; + case "getLastRequestId": + return 10n; + case "getLastFinalizedRequestId": + return 8n; + default: + throw new Error(`unexpected functionName: ${req.functionName}`); + } + }; + + const snapshots = await pollVaults( + { readContract }, + { chain: "mainnet", forcedRebalanceThresholdBP: 900, pdgAddress: ADDR_PDG }, + [{ vault: ADDR_VAULT, pool: ADDR_POOL, vault_name: "vault-a", withdrawalQueue: ADDR_WQ, dashboard: ADDR_DASH }], + ADDR_STETH, + ADDR_HUB + ); + + assert.equal(snapshots.length, 1); + assert.equal(snapshots[0].vault_name, "vault-a"); + assert.equal(snapshots[0].healthFactorPct, 180); + assert.equal(snapshots[0].utilizationPct, 50); + assert.equal(snapshots[0].unfinalizedRequests, 2n); + assert.equal(snapshots[0].pdgTotalWei, 5n * 10n ** 18n); + assert.equal(snapshots[0].pdgLockedWei, 2n * 10n ** 18n); + assert.equal(snapshots[0].pdgUnlockedWei, 3n * 10n ** 18n); + assert.equal(snapshots[0].pdgPendingActivations, 4n); + assert.equal(snapshots[0].pdgPolicy, 2n); + assert.equal(snapshots[0].nodeOperator, ADDR_NODE_OPERATOR); + + const metrics = await register.metrics(); + assert.match(metrics, /lido_vault_total_value_eth/); + assert.match(metrics, /lido_vault_inactive_eth/); + assert.match(metrics, /lido_vault_node_operator_fee_eth/); + assert.match(metrics, /lido_vault_pdg_total_eth/); + assert.match(metrics, /lido_vault_pdg_locked_eth/); + assert.match(metrics, /lido_vault_pdg_unlocked_eth/); + assert.match(metrics, /lido_vault_pdg_pending_activations/); + assert.match(metrics, /lido_vault_pdg_policy/); + assert.match(metrics, /lido_wq_unfinalized_requests/); + assert.match(metrics, /vault_name="vault-a"/); +}); + +test("PDG metrics are not NaN when nodeOperatorBalance returns array (viem runtime format)", async () => { + const readContract = async (req) => { + switch (req.functionName) { + case "totalValue": return 100n * 10n ** 18n; + case "isVaultHealthy": return true; + case "healthShortfallShares": return 0n; + case "liabilityShares": return 0n; + case "totalMintingCapacityShares": return 100n; + case "isReportFresh": return true; + case "withdrawableValue": return 10n * 10n ** 18n; + case "vaultConnection": return { forcedRebalanceThresholdBP: 1000, reserveRatioBP: 0 }; + case "availableBalance": return 5n * 10n ** 18n; + case "stagedBalance": return 1n * 10n ** 18n; + case "nodeOperator": return ADDR_NODE_OPERATOR; + case "accruedFee": return 0n; + case "pdgPolicy": return 0n; + case "getPooledEthByShares": return req.args[0]; + case "nodeOperatorBalance": + // Simulate viem array return (must NOT use .total / .locked — would be undefined) + return [3n * 10n ** 18n, 1n * 10n ** 18n]; + case "unlockedBalance": return 2n * 10n ** 18n; + case "pendingActivations": return 0n; + case "unfinalizedRequestsNumber": return 0n; + case "unfinalizedAssets": return 0n; + case "getLastRequestId": return 0n; + case "getLastFinalizedRequestId": return 0n; + default: throw new Error(`unexpected functionName: ${req.functionName}`); + } + }; + + const snapshots = await pollVaults( + { readContract }, + { chain: "hoodi", forcedRebalanceThresholdBP: 1000, pdgAddress: ADDR_PDG }, + [{ vault: ADDR_VAULT, vault_name: "vault-b", withdrawalQueue: ADDR_WQ, dashboard: ADDR_DASH }], + ADDR_STETH, + ADDR_HUB + ); + + const snap = snapshots[0]; + + // Core regression: must never be NaN or undefined + assert.ok(!Number.isNaN(Number(snap.pdgTotalWei)), "pdgTotalWei must not be NaN"); + assert.ok(!Number.isNaN(Number(snap.pdgLockedWei)), "pdgLockedWei must not be NaN"); + assert.ok(!Number.isNaN(Number(snap.pdgUnlockedWei)), "pdgUnlockedWei must not be NaN"); + assert.notEqual(snap.pdgTotalWei, undefined, "pdgTotalWei must not be undefined"); + assert.notEqual(snap.pdgLockedWei, undefined, "pdgLockedWei must not be undefined"); + + assert.equal(snap.pdgTotalWei, 3n * 10n ** 18n); + assert.equal(snap.pdgLockedWei, 1n * 10n ** 18n); + assert.equal(snap.pdgUnlockedWei, 2n * 10n ** 18n); + + // Verify Prometheus metrics are also not NaN + const metrics = await register.metrics(); + assert.doesNotMatch(metrics, /lido_vault_pdg_total_eth.*NaN/, "pdg_total_eth must not be NaN in Prometheus output"); + assert.doesNotMatch(metrics, /lido_vault_pdg_locked_eth.*NaN/, "pdg_locked_eth must not be NaN in Prometheus output"); +}); + +test("PDG metrics default to zero when pdgAddress is not configured", async () => { + const readContract = async (req) => { + switch (req.functionName) { + case "totalValue": return 100n * 10n ** 18n; + case "isVaultHealthy": return true; + case "healthShortfallShares": return 0n; + case "liabilityShares": return 0n; + case "totalMintingCapacityShares": return 100n; + case "isReportFresh": return true; + case "withdrawableValue": return 10n * 10n ** 18n; + case "vaultConnection": return { forcedRebalanceThresholdBP: 1000, reserveRatioBP: 0 }; + case "availableBalance": return 5n * 10n ** 18n; + case "stagedBalance": return 1n * 10n ** 18n; + case "nodeOperator": return ADDR_NODE_OPERATOR; + case "accruedFee": return 0n; + case "pdgPolicy": return 0n; + case "getPooledEthByShares": return req.args[0]; + case "unfinalizedRequestsNumber": return 0n; + case "unfinalizedAssets": return 0n; + case "getLastRequestId": return 0n; + case "getLastFinalizedRequestId": return 0n; + default: throw new Error(`unexpected functionName: ${req.functionName}`); + } + }; + + const snapshots = await pollVaults( + { readContract }, + { chain: "hoodi", forcedRebalanceThresholdBP: 1000, pdgAddress: null }, + [{ vault: ADDR_VAULT, vault_name: "vault-c", withdrawalQueue: ADDR_WQ, dashboard: ADDR_DASH }], + ADDR_STETH, + ADDR_HUB + ); + + const snap = snapshots[0]; + assert.equal(snap.pdgTotalWei, 0n); + assert.equal(snap.pdgLockedWei, 0n); + assert.equal(snap.pdgUnlockedWei, 0n); + assert.equal(snap.pdgPendingActivations, 0n); +}); diff --git a/tests/monitors/withdrawalMonitor.test.js b/tests/monitors/withdrawalMonitor.test.js new file mode 100644 index 0000000..05db2ec --- /dev/null +++ b/tests/monitors/withdrawalMonitor.test.js @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { hasUnfinalizedRequests } from "../../src/monitors/withdrawalMonitor.js"; + +test("hasUnfinalizedRequests returns false for zero", () => { + assert.equal(hasUnfinalizedRequests(0n), false); +}); + +test("hasUnfinalizedRequests returns true for positive values", () => { + assert.equal(hasUnfinalizedRequests(1n), true); + assert.equal(hasUnfinalizedRequests(999999999n), true); +}); diff --git a/tests/notifications/discord.test.js b/tests/notifications/discord.test.js new file mode 100644 index 0000000..59b417a --- /dev/null +++ b/tests/notifications/discord.test.js @@ -0,0 +1,138 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const ORIGINAL_FETCH = globalThis.fetch; + +async function loadDiscordModule() { + return import(`../../src/notifications/discord.js?test=${Date.now()}-${Math.random()}`); +} + +test.after(() => { + globalThis.fetch = ORIGINAL_FETCH; +}); + +test("shouldSendAlert applies cooldown by alert key", async () => { + const { shouldSendAlert } = await loadDiscordModule(); + const originalNow = Date.now; + let now = 1000; + Date.now = () => now; + + assert.equal(shouldSendAlert("k1", 300), true); + assert.equal(shouldSendAlert("k1", 300), false); + now = 1401; + assert.equal(shouldSendAlert("k1", 300), true); + + Date.now = originalNow; +}); + +test("sendDiscordEmbed sends POST body and propagates webhook errors", async () => { + const { sendDiscordEmbed } = await loadDiscordModule(); + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + return { + ok: true, + status: 204, + text: async () => "", + }; + }; + + await sendDiscordEmbed("https://discord.example/webhook", { + title: "Alert", + color: 123, + fields: [{ name: "A", value: "B" }], + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://discord.example/webhook"); + assert.equal(calls[0].options.method, "POST"); + + globalThis.fetch = async () => ({ + ok: false, + status: 400, + text: async () => "bad payload", + }); + + await assert.rejects( + () => + sendDiscordEmbed("https://discord.example/webhook", { + title: "Broken", + color: 1, + }), + /Discord webhook failed 400: bad payload/ + ); +}); + +test("setWebhookIdentity injects username and avatar_url in payload", async () => { + const { setWebhookIdentity, sendDiscordEmbed } = await loadDiscordModule(); + let payload = null; + globalThis.fetch = async (_url, options) => { + payload = JSON.parse(options.body); + return { ok: true, status: 204, text: async () => "" }; + }; + + setWebhookIdentity({ username: "BotName", avatarUrl: "https://img.example/a.png" }); + await sendDiscordEmbed("https://discord.example/webhook", { title: "T", color: 1 }); + + assert.equal(payload.username, "BotName"); + assert.equal(payload.avatar_url, "https://img.example/a.png"); + assert.equal(Array.isArray(payload.embeds), true); +}); + +test("sendInactiveEthAlert sends once within cooldown", async () => { + const { sendInactiveEthAlert } = await loadDiscordModule(); + const originalNow = Date.now; + let now = 10_000; + Date.now = () => now; + + let callCount = 0; + let title = ""; + globalThis.fetch = async (_url, options) => { + callCount += 1; + const body = JSON.parse(options.body); + title = body.embeds[0].title; + return { ok: true, status: 204, text: async () => "" }; + }; + + await sendInactiveEthAlert( + "https://discord.example/webhook", + 1000, + "Vault A", + "0x1111111111111111111111111111111111111111", + 5n * 10n ** 18n + ); + await sendInactiveEthAlert( + "https://discord.example/webhook", + 1000, + "Vault A", + "0x1111111111111111111111111111111111111111", + 5n * 10n ** 18n + ); + + assert.equal(callCount, 1); + assert.match(title, /Inefficient ETH/); + + Date.now = originalNow; +}); + +test("sendUnfinalizedRequestsAlert uses critical color when there is deficit", async () => { + const { sendUnfinalizedRequestsAlert } = await loadDiscordModule(); + let color = 0; + globalThis.fetch = async (_url, options) => { + const body = JSON.parse(options.body); + color = body.embeds[0].color; + return { ok: true, status: 204, text: async () => "" }; + }; + + await sendUnfinalizedRequestsAlert( + "https://discord.example/webhook", + 1, + "Vault A", + "0x1111111111111111111111111111111111111111", + 2, + 5n * 10n ** 18n, + 1n * 10n ** 18n + ); + + assert.equal(color, 0xe74c3c); +});