Multi-chain validator/pool analytics as an API + light dashboard + alerts. MVP chain: Solana (live). Next: Ethereum Beacon, Cosmos Hub, Bitcoin pools.
-
Normalized operator metrics (per chain)
- Availability: hourly buckets → 24h/7d uptime
- Yield/APY: daily snapshots (MVP:
inflation.validator × (1 - commission)) - Stake flows: daily net change (MVP: votingPower diffs)
- States: commission, delinquent/flags, voting power
-
Alerts: rules + signed webhooks (
x-sp-signatureHMAC-SHA256) -
Docs: Swagger UI at
/docs(raw/v0/openapi.json) -
Dashboard (starter): Next.js + Mantine (validators list/detail, alerts CRUD, status)
- Backend: NestJS (Fastify) + TypeScript (CommonJS)
- DB: Postgres 16 via Prisma ORM
- Cache/Jobs: Redis + BullMQ (workers & scheduler)
- Containers: Docker Compose (
postgres,redis,migrate,api,worker) - Auth/limits:
x-api-key(SHA-256 stored). Tiers w/ Redis sliding window Defaults: Free 30/min, Pro 60/min, Team 240/min.
Public health
GET /v0/status
Operators
GET /v0/chains/:chain/operators?min_availability&max_commission&limit→ rows joined with latest state, 24h availability, latest APY, 7d net flowGET /v0/chains/:chain/operators/:id/metrics?window=24h|7d|30d&granularity=1h|6h|1d&from&to→{ meta, availability[{t, produced, missed, uptime_pct}], yields[{t, apy}], flows[{t, net}] }
Alerts (QoL)
POST /v0/alerts/webhooks{ url, secret }GET /v0/alerts/webhooks(includes their rules)POST /v0/alerts/webhooks/:id/ping{ payload? }(signed test)DELETE /v0/alerts/webhooks/:id(cascades rules)POST /v0/alerts/rules{ chainId, operatorId?, kind, threshold, window, webhookId }Kinds (MVP):availability_below,apr_belowGET /v0/alerts/rules?webhookId=...DELETE /v0/alerts/rules/:id- (Optional
PATCHfor enable/threshold/window)
Auth header for protected routes: x-api-key: <your_key>
- operators_poll (5m):
getVoteAccounts→Operatorupsert +OperatorStatesnapshot - availability_bucket (5m): last full hour via
getBlockProduction→ one 1h bucket/operator - apy_snapshot (daily 00:15 UTC):
inflationRate.validator × (1 - commission)→YieldSnapshot - stakeflow_rollup (daily 00:45 UTC): day-over-day diff of latest
votingPower→StakeFlowDaily - alerts_eval (1m): compute windows (24h uptime, 7d APR) → POST to matching webhooks
Queues: operators_poll, availability_bucket, apy_snapshot, stakeflow_rollup, alerts_eval
Repeats: registered at API boot (BullMQ repeatables in Redis)
Chain(id, name, nativeSymbol)Operator(chainId, operatorId, kind, name, website)PK(chainId, operatorId)OperatorState(chainId, operatorId, ts, commission, delinquent, votingPower)PK(chainId, operatorId, ts)AvailabilityBucket(chainId, operatorId, bucketStart, produced, missed, pct)PK(chainId, operatorId, bucketStart)YieldSnapshot(chainId, operatorId, ts, apy)PK(chainId, operatorId, ts)StakeFlowDaily(chainId, operatorId, day, netChangeBaseUnits)PK(chainId, operatorId, day)Webhook(id, userId?, url, secret, isActive)AlertRule(id, userId?, chainId, operatorId?, kind, threshold, window, webhookId, isActive)ApiKey(id, userId?, keyHash, tier)JobState(jobName, lastSlot?, lastEpoch?, lastHeight?, lastRun?)
Indexes exist on (chainId, operatorId, ts|bucketStart|day) across time-series tables.
# Build & start
docker compose up -d --build
# Quick smoke tests
curl http://localhost:3000/v0/status
curl -H 'x-api-key: sp_dev_key_123' "http://localhost:3000/v0/chains/solana/operators?limit=5"
curl -H 'x-api-key: sp_dev_key_123' "http://localhost:3000/v0/chains/solana/operators/<votePubkey>/metrics?window=7d"Env (typical)
DATABASE_URL=postgresql://stakepulse:stakepulse@postgres:5432/stakepulse?schema=public
REDIS_URL=redis://redis:6379
SOLANA_RPC_URL=...
SOLANA_RPC_COMMITMENT=confirmed
ENABLE_DOCS=true
DEV_API_KEY=sp_dev_key_123
Swagger: visit /docs (persist auth) or fetch /v0/openapi.json.
Script: src/scripts/backfill.ts → dist/scripts/backfill.js
# Availability + APY (last 7 days)
docker compose exec api node dist/scripts/backfill.js --chain solana --days 7 --availability --apy
# Stake flows (votingPower diffs)
docker compose exec api node dist/scripts/backfill.js --chain solana --days 7 --flowsAvailability backfill uses approximate slot→time mapping (good for 1h buckets).
- CommonJS for dev (avoid ESM path/extension pain)
- Prisma client is generated inside the API image (prevents init errors)
- Prisma columns use camelCase; quote identifiers in raw SQL (e.g.,
"chainId") - ioredis: pass URL as first arg; avoid
path:; setmaxRetriesPerRequest: nullfor BullMQ - Schedulers currently boot with the API (can extract later)
- Polish: status health, hardened backfill, exact slot bounds (later), instruction-scan stake flows (later)
- Accounts:
User+ ownership onApiKey/Webhook, magic-link auth, Stripe billing/webhooks - Chains: ETH Beacon (attestation %, proposer success, APR), Cosmos (precommit miss, inflation APR), Bitcoin pools
- Alerts: more kinds (
participation_below,commission_above,net_flow_below), HMAC timestamp + replay guard, DLQ