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
+
+[](https://github.com/stakely/stvaults-watcher/actions/workflows/ci.yml)
+[](https://nodejs.org)
+[](package.json)
+[](https://hub.docker.com/r/stakely/stvaults-watcher)
+[](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.
+
+
+
+
+
+## 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`.
+
+
+
+
+
+## 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);
+});