A small Rust client that connects to a Substrate node over WebSocket, verifies which chain the node is actually serving, reports its identity and health over JSON-RPC, and can follow new blocks as they are produced.
Previously
substrate_handshake. The old URL redirects.
$ substrate-node-probe --node-address wss://rpc.polkadot.io \
--genesis-hash 91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3
INFO Connecting to node at wss://rpc.polkadot.io
INFO Genesis hash verified: 0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3
INFO Received response for request id 1: {"id":1,"jsonrpc":"2.0","result":"Parity Polkadot"}
INFO Received response for request id 2: {"id":2,"jsonrpc":"2.0","result":"Polkadot"}
INFO Received response for request id 3: {"id":3,"jsonrpc":"2.0","result":"1.24.0-660acefe665"}
INFO Node health: 65 peer(s), syncing=Some(false)
INFO Node information queried!
- Connects to the node's JSON-RPC endpoint over WebSocket (
ws://orwss://). - Checks chain identity by asking for the hash of block 0 via
chain_getBlockHashand comparing it to the--genesis-hashyou supplied. A node on a different chain is rejected and the client exits non-zero without querying anything further. - Queries node information —
system_name,system_chain,system_version,system_healthandchain_getHeader— matching each response to its request by JSON-RPC id, since nodes are free to answer out of order (and do). All five go out before any reply is read, so the step costs one round trip rather than five. - Follows new blocks, optionally — with
--follow Nit subscribes viachain_subscribeNewHeads, reports headers as the node pushes them, then unsubscribes. - Reports, either as logs through
env_logger(RUST_LOG=debugshows the full exchange) or as a single JSON object with--json.
--json prints one object to stdout. Logging stays on stderr, so stdout carries nothing but the report:
$ substrate-node-probe --node-address wss://rpc.polkadot.io --follow 2 --json 2>/dev/null
{
"endpoint": "wss://rpc.polkadot.io",
"ok": true,
"genesis_hash": "0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3",
"genesis_verified": true,
"rpc_latency_ms": 58,
"name": "Parity Polkadot",
"chain": "Polkadot",
"version": "1.24.0-660acefe665",
"peers": 69,
"is_syncing": false,
"should_have_peers": true,
"best_block": 32264078,
"heads_followed": 2
}
Three properties worth relying on:
- A failed run still prints a report —
"ok": falsewith afailureand anerror, plus whatever was gathered before the failure. A probe that goes silent exactly when the node breaks is no use to whatever is parsing it. A genesis mismatch, for instance, still reports the hash the node actually serves. - Fields the node did not answer are omitted, not
null—"peers": 0means an isolated node; a missingpeersmeans the node never said.system_healthis not exposed everywhere, and a refusal costs only its own fields rather than the whole report. - Nothing requires parsing prose. The
errorstring is for humans and is free to be reworded;failureis the part that is safe to alert on.
genesis_verified is true only when --genesis-hash was supplied and matched; without the flag the hash is reported but nothing about it is proven. should_have_peers is what makes peers: 0 interpretable — it is false on a dev chain running alone. best_block is the node's current height, which is how you tell a stuck node from a healthy one; the probe reports it but cannot judge staleness on its own, because a Substrate header carries no timestamp — compare it across runs, or against a second node.
On failure, failure carries one of these. They are a stable contract; the prose in error is not.
failure |
Meaning |
|---|---|
config |
The command line asked for something impossible. The node is blameless. |
connect |
The connection was never established. |
timeout |
A wait expired. The node may be up but is not answering in time. |
transport |
The connection failed or closed part-way through. |
protocol |
The node answered with something unusable — bad JSON, or a result of the wrong shape. |
rpc_error |
The node returned a JSON-RPC error for a call the probe needs. |
genesis_mismatch |
The node is serving a different chain than you required. |
requirement_unmet |
The node is reachable and correct, but failed a --require-* bar. |
The distinction that motivates this: connect and requirement_unmet are both "the probe exited 1", but one means your monitoring cannot see the node and the other means the node is up and telling you it is unhealthy. Those usually want different alerts.
case "$(substrate-node-probe --node-address "$RPC" --require-peers 1 --json 2>/dev/null | jq -r '.failure // "ok"')" in
ok) page=none ;;
requirement_unmet) page=degraded ;;
connect|timeout) page=unreachable ;;
*) page=investigate ;;
esacSteps 1–3 don't need it — request/response would work as well over HTTP POST. --follow is the part that does: a subscription has the node push frames unprompted for as long as the connection lives, which is what the transport is actually for.
$ substrate-node-probe --node-address wss://rpc.polkadot.io --follow 3
INFO Subscribed with id JfjLVdZ5TGesJIVX
INFO New head #32263282 parent=0xb2ddbc8a89c37c57b07136cf2c997c8074b0c7de309f19eae00a990c2e3bfe8c
INFO New head #32263283 parent=0x52aa1f4c48e680c82f9a5838f8e1f16f546e427b03c842e572e4b3225713e766
INFO New head #32263284 parent=0x0e0c80ba8389731ff05c28a2810a3093d018216b12bbe2fd11cc70982c093397
INFO Unsubscribed after 3 header(s)
This talks to a node's RPC endpoint, not its peer-to-peer port. Verifying the genesis hash confirms which chain you are connected to; it is not peer authentication, which is a libp2p protocol on port 30333 requiring Noise, multistream-select and yamux.
The HandshakeMessage struct in src/scale.rs is a worked example of SCALE encoding — Substrate's wire codec — kept with a round-trip test. It is deliberately not sent to the node, because an RPC endpoint has no notion of it.
→ substrate-node-probe.onrender.com — runs the real binary → emilbob.github.io/substrate-node-probe — runs in your tab
The same checks against a live node, with results filling in as they resolve. Source is web/index.html — one self-contained file, no build step.
The page runs the checks one of two ways, and always says which:
| Engine | Where | What actually runs |
|---|---|---|
| Rust | Render | The real binary, server-side. The report is its output, unmodified. |
| JavaScript | GitHub Pages, or any static host | A reimplementation of the checks in the browser tab, connecting straight to the node. Nothing is proxied. |
Render's free tier sleeps after ~15 minutes idle, so the first request after a quiet spell takes a while to wake. The Pages copy has no backend to wait on and is always instant — which is the honest trade between the two.
The page asks /api/health on load; if a backend answers, it offers the switch and defaults to Rust. On a static host there is nothing to switch to, so it stays in browser mode and says so rather than implying the binary ran.
The JavaScript engine is a genuine reimplementation, not a shim — same request ids, same pipelined calls, same per-step deadlines, same eight failure kinds, same rule that an unprovable requirement fails. It exists so the page is useful without a server, not to pretend to be the binary.
Locally, either way:
python3 -m http.server -d web 8000 # static, JavaScript engine only
cargo run --features server --bin serve # http://127.0.0.1:8080, both enginesNote that a page served over https cannot open a ws:// connection to 127.0.0.1 — browsers block that as mixed content — so probing a local dev node from the hosted page is not possible. Use the CLI, which has no such restriction.
serve is a small HTTP wrapper around the same probe() the CLI calls: GET /api/health, POST /api/probe, and the static page on everything else so the two share an origin.
cargo run --features server --bin serve
curl -s localhost:8080/api/probe -H 'Content-Type: application/json' \
-d '{"endpoint":"wss://rpc.polkadot.io","require_peers":1}' | jqIt is feature-gated, so a plain cargo build still produces only the CLI without compiling a web stack it will never call.
render.yaml provisions it on Render from the Dockerfile — New → Blueprint → pick this repo, nothing to configure by hand. That is what runs at substrate-node-probe.onrender.com.
An endpoint field that becomes an outbound connection is server-side request forgery. guard.rs resolves the host and refuses anything that is not on the public internet — loopback, RFC1918, link-local (169.254.169.254 is where cloud metadata lives), carrier-grade NAT, multicast, and the IPv4-mapped IPv6 forms of all of them. The server also caps follow, shortens the header wait, limits the request body and bounds the whole request.
None of this applies to the CLI, which defaults to ws://127.0.0.1:9944 and must keep working against a local dev node. The difference is not the address, it's who is asking.
One limit worth stating plainly: the guard resolves the name and the WebSocket client resolves it again, so a record that changes in between could still slip past (DNS rebinding). Closing that needs the connection pinned to the address already checked, which the client does not expose. The service therefore also holds no secrets and has nothing worth reaching.
| File | Holds |
|---|---|
src/lib.rs |
The probe itself, so the CLI and the server run the same code. |
src/main.rs |
The CLI flags. |
src/bin/serve.rs |
The HTTP wrapper, and the limits that apply only to strangers. |
src/rpc.rs |
The transport: opening the WebSocket, request ids, timeouts, reading frames. Knows nothing about chains. |
src/probe.rs |
The JSON-RPC calls — genesis hash, identity, health and height, following heads. |
src/report.rs |
The findings, their JSON shape, and the --require-* checks. |
src/error.rs |
The failure taxonomy. |
src/guard.rs |
The SSRF check. Used by the server only, never the CLI. |
src/scale.rs |
The SCALE codec example. |
web/index.html |
The browser page, and the JavaScript engine. |
- Rust and Cargo (rustup.rs)
- A Substrate node to talk to — either a public endpoint or a local one (see below)
cargo run -- [--node-address <url>] [--genesis-hash <hex>]| Flag | Default | Meaning |
|---|---|---|
--node-address |
ws://127.0.0.1:9944 |
WebSocket RPC endpoint. wss:// is supported. |
--genesis-hash |
(none) | Hash the node must report, with or without a 0x prefix. Omit to report the node's genesis hash without enforcing one. |
--follow <COUNT> |
(none) | Follow this many new block headers after querying, then unsubscribe and exit. Omit to exit straight after the query. |
--require-peers <N> |
(none) | Fail unless the node reports at least N connected peers. |
--require-synced |
(off) | Fail if the node is still syncing. |
--json |
(off) | Print the findings to stdout as one JSON object. Logs stay on stderr. |
--connect-timeout <SECS> |
10 |
Seconds to allow for the connection. |
--rpc-timeout <SECS> |
10 |
Seconds to allow the node to answer a request. |
--head-timeout <SECS> |
120 |
Seconds to allow for each pushed header under --follow. Raise it for a slow parachain. |
Exit code is 0 on success and 1 on any failure, so it works as a health check directly:
substrate-node-probe --node-address wss://rpc.polkadot.io \
--require-peers 1 --require-synced || alert "polkadot rpc is unusable"Without --require-* the exit code only means the node answered. A node can answer every query correctly, on the right chain, and still be useless — connected to nobody, or serving stale state while it catches up. The flags are what make the exit code mean the node is usable:
ERROR node has 0 peer(s), --require-peers demands 1
ERROR node is still syncing
A requirement that cannot be evaluated fails. If the node will not say how many peers it has — system_health is not exposed everywhere — then --require-peers has not been met, and the probe says so rather than passing. Treating silence as success is how a health check comes to certify a broken node.
Requirements are judged before --follow, so a node that has already failed is not watched for another block.
These genesis hashes were verified against the live networks:
| Chain | Endpoint | Genesis hash |
|---|---|---|
| Polkadot | wss://rpc.polkadot.io |
91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3 |
| Kusama | wss://kusama-rpc.polkadot.io |
b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe |
| Westend | wss://westend-rpc.polkadot.io |
e143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e |
Pointing the Polkadot hash at Kusama is rejected, which is the whole point of the check:
ERROR genesis hash mismatch — expected b0a8d493…3dafe, node reports 91b171bb…ce90c3
The old substrate-node-template has been superseded by the Polkadot SDK solochain template:
git clone https://github.com/paritytech/polkadot-sdk-solochain-template
cd polkadot-sdk-solochain-template
cargo build --release
./target/release/solochain-template-node --devThen, in another terminal:
cargo run # defaults to ws://127.0.0.1:9944A dev chain's genesis hash depends on its chainspec and runtime build, so there is no fixed value to check against — run without --genesis-hash, note the hash the client reports, and pass it back if you want the check enforced on later runs.
cargo test # unit tests, incl. mock-node integration
cargo test -- --ignored # plus the live-network probe (see below)
cargo clippy --all-targets # lints
cargo fmt --check # formattingTests run against an in-process mock WebSocket node, so they need no network and no running Substrate node. Timeouts are injectable, so the timeout paths are covered with short real durations and the whole suite finishes in well under a second.
The mock node answers whatever the test file tells it to, so it can only prove the client is self-consistent — it would keep passing if an RPC method were renamed, a result shape changed, or the wss:// and rustls path broke. One #[ignore]d test runs the whole probe against wss://rpc.polkadot.io and asserts on the real answers.
It is excluded from cargo test and never runs on a pull request, because it depends on a third party being up: a PR failing because someone else's endpoint blinked is the false alarm that teaches you to ignore CI. live-probe.yml runs it weekly instead, retrying twice before reporting — one dropped connection is noise, three in a row is a signal.
- TLS uses
rustlswith theringprovider, so the build needs no OpenSSL system libraries. - Every wait is bounded, so a node that accepts the socket and goes silent produces an error rather than a hang. The bounds apply to a step as a whole, not to each frame within it: the read loops skip frames they do not recognise, and a per-frame clock would let a node emitting one unrelated frame just inside the limit keep the probe waiting forever — every individual wait short, the total unbounded. Waiting for a pushed block header gets a fresh budget per header, since that waits on the chain's block time rather than on the node being responsive.