fork monitoring service for substrate-based chains. tracks forks in real-time across multiple chains, attributes them to block authors, and determines whether parachain forks were caused by relay chain forks or collator contention.
┌────────────────────────────────────────────┐
│ forkwatch │
│ │
┌──────────┐ wss:// │ ┌────────────┐ ┌──────────────────┐ │
│ polkadot ├────────────►│ │ │ │ fork detector │ │
│ (babe) │ allHeads │ │ │ │ │ │
└──────────┘ newHeads │ │ ├───►│ block tree │ │
finalized │ │ chain │ │ depth tracking │ │
│ │ manager │ │ author metrics │ │
┌──────────┐ wss:// │ │ │ └────────┬─────────┘ │
│hydration ├────────────►│ │ │ │ │
│ (aura) │ │ │ │ ┌────────▼─────────┐ │
└──────────┘ │ │ │ │ causation │ │
│ │ │ │ │ │
┌──────────┐ wss:// │ │ │ │ relay parent │ │
│ assethub ├────────────►│ │ │ │ comparison │ │
│ (aura) │ │ │ │ │ │ │
└──────────┘ │ │ │ │ relay_fork vs │ │
│ │ │ │ collator_ │ │
┌──────────┐ wss:// │ │ │ │ contention │ │
│ moonbeam ├────────────►│ │ │ └──────────────────┘ │
│ (aura) │ │ └────────────┘ │
└──────────┘ │ │
│ ┌────────────┐ ┌──────────────────┐ │
│ │ prometheus │ │ identity │ │
│ │ metrics │◄───┤ resolver │ │
│ │ :3001 │ │ │ │
│ └─────┬──────┘ │ on-chain names │ │
│ │ │ via IdentityOf │ │
└────────┼───────────┴──────────────────┘ │
│ │
┌────────┼────────────────────────────┐ │
│ │ optional │ │
│ ┌─────▼──────┐ │ │
│ │ grafana │ ┌──────────────┐ │ │
│ │ ├───►│ postgresql │◄┼──────┘
│ │ dashboards │ │ fork events │ │
│ └────────────┘ │ block logs │ │
│ │ finality │ │
│ └──────────────┘ │
└─────────────────────────────────────┘
each substrate node exposes chain_subscribeAllHeads which delivers every imported block, including fork branches. when two blocks appear at the same height with different hashes, that's a fork.
for parachain forks, forkwatch compares the relay parent reference in each competing block:
- different relay parents →
relay_fork(the relay chain forked, parachain followed) - same relay parent →
collator_contention(multiple collators produced competing blocks)
block authors are resolved from the consensus digest (aura/babe) and enriched with on-chain identity names via identity.identityOf / identity.superOf, cached permanently.
# run locally (no database required)
npm install
node src/index.js
# with docker
docker compose upby default it connects to polkadot, hydration, assethub, and moonbeam using public rpcs. override with the CHAINS env variable.
| env variable | default | description |
|---|---|---|
CHAINS |
polkadot + hydration + assethub + moonbeam | json array of chain configs |
DATABASE_URL |
(none) | postgresql connection string, omit to run without db |
PORT |
3001 | http server port |
TIMEOUT |
120 | seconds without a block before reconnecting a node |
PRUNE_FINALIZED_AFTER |
50 | blocks behind finalized to keep in memory |
RETENTION_DAYS |
90 | days to keep fork_blocks and finality_log in db |
FORK_EVENT_RETENTION_DAYS |
365 | days to keep fork_events in db |
FINALITY_LOG_INTERVAL |
60 | seconds between finality log samples |
[
{
"name": "hydration",
"consensus": "aura",
"nodes": [
{ "name": "gc", "url": "wss://rpc.hydradx.cloud" },
{ "name": "dwellir", "url": "wss://hydration-rpc.n.dwellir.com" }
],
"knownAuthors": { "5Grw...": "alice" }
}
]consensus:aurafor parachains,babefor relay chains (informational, polkadot.js handles both)knownAuthors: optional manual name overrides, on-chain identity is resolved automatically- causation attribution is enabled automatically when both a relay chain (babe) and parachains (aura) are configured
GET /metrics prometheus scrape endpoint
GET /api/status chain states, node connections, heights
GET /api/forks?limit=100 recent fork events across all chains
GET /api/forks/:chain fork events for a specific chain
GET /api/blocks/:chain/:height competing blocks at a forked height
GET /api/health liveness probe
all prefixed forkwatch_:
| metric | type | labels | description |
|---|---|---|---|
fork_events_total |
counter | chain | fork occurrences |
fork_depth |
histogram | chain | fork depth distribution |
active_fork_heights |
gauge | chain | currently unresolved forks |
author_fork_blocks_total |
counter | chain, author | forked blocks per author |
author_blocks_total |
counter | chain, author | total blocks per author |
best_block_height |
gauge | chain, node | unfinalized head |
finalized_block_height |
gauge | chain, node | finalized head |
finality_lag_blocks |
gauge | chain, node | best - finalized gap |
node_connected |
gauge | chain, node | connection health (0/1) |
blocks_imported_total |
counter | chain, node | blocks seen per node |
parachain_fork_cause_total |
counter | chain, cause | forks by cause |
parachain_forks_relay_caused_total |
counter | chain, relay_chain | relay-attributed forks |
set DATABASE_URL to enable postgresql storage. schema is auto-created on startup.
three tables:
- fork_blocks — every block at forked heights (hash, author, relay parent, seen by which nodes)
- fork_events — one row per fork (competing count, cause, depth, resolved hash)
- finality_log — periodic finality lag snapshots for long-term analysis
grafana can query both prometheus and postgresql as data sources.
swarm-compatible stack file included:
docker stack deploy -c docker-compose.yml forkwatchimage: galacticcouncil/forkwatch:latest
the service is exposed via traefik at https://forkwatch.play.hydration.cloud. endpoints:
https://forkwatch.play.hydration.cloud/metrics— prometheus scrapehttps://forkwatch.play.hydration.cloud/api/status— service statushttps://forkwatch.play.hydration.cloud/api/forks— recent fork events
add the following scrape config to your prometheus.yml:
scrape_configs:
- job_name: 'forkwatch'
scrape_interval: 15s
static_configs:
- targets: ['forkwatch.play.hydration.cloud']
scheme: httpsor if prometheus runs inside the same swarm network, scrape the service directly without tls:
scrape_configs:
- job_name: 'forkwatch'
scrape_interval: 15s
static_configs:
- targets: ['app:3001']
# use the swarm service name and internal portadd two data sources:
- prometheus — query
forkwatch_*metrics for real-time dashboards - postgresql — connect to
db:5432(db:forkwatch, user:forkwatch) for detailed fork event queries
three pre-built dashboards are in grafana/:
| dashboard | datasource | file | description |
|---|---|---|---|
| real-time metrics | prometheus | prometheus-dashboard.json |
fork rate, finality lag, depth heatmap, author attribution, node connections, cause breakdown |
| fork analysis | postgresql | postgres-dashboard.json |
fork event table, daily trends, top authors, depth distribution, competing block detail, relay fork tracing |
| combined overview | both | combined-dashboard.json |
finality lag + fork events overlay, relay chain → parachain causation correlation, fork resolution time, author analysis with cause breakdown |
import via grafana ui: dashboards → import → upload json file. select your prometheus and postgresql datasources when prompted.
- all chains and nodes connect in parallel
- a failed node doesn't affect other nodes or chains
- per-node watchdog reconnects silently after timeout (no process restart)
- WsProvider handles transient websocket disconnects automatically
- database is optional — service runs fine without it (console + prometheus only)
npm test53 tests across 4 suites: block-tree, fork-detector, causation, queries.
src/
index.js entrypoint, metric registration, api routes
config.js dotenv config with default chain list
metrics.js prom-client MetricsRegistry
endpoints.js express EndpointRegistry
db/
schema.sql postgresql table definitions
index.js connection pool with retry
queries.js insert/query/cleanup (no-op when db disabled)
chain/
connection.js WsProvider + ApiPromise per node with timeout
manager.js multi-chain lifecycle, subscriptions, reconnection
monitor/
block-tree.js in-memory block tree (~100KB, pruned on finalization)
fork-detector.js fork detection, metrics, db logging
author-extractor.js aura/babe author + on-chain identity resolution
causation.js relay parent comparison for fork cause attribution
finality-tracker.js best/finalized lag tracking with periodic db sampling
Apache-2.0