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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .cursor/rules/coding-conventions.mdc
Original file line number Diff line number Diff line change
@@ -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.
136 changes: 136 additions & 0 deletions .cursor/rules/project-overview.mdc
Original file line number Diff line number Diff line change
@@ -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`.
25 changes: 25 additions & 0 deletions .cursor/rules/testing-conventions.mdc
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.env
.git
*.log
.DS_Store
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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"}]
3 changes: 3 additions & 0 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Security Policy

If you discover any security related issues, please contact with admin@stakely.io
68 changes: 68 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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

4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.env
*.log
.DS_Store
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
Loading