DEV Community

xmrlist
xmrlist

Posted on

How to health-check Monero remote nodes (sync, Tor, CORS and uptime) in about 80 lines of Python

Most Monero wallets ask you to pick a remote node, and most people pick the first one in the list. That works until the node is stuck 40 blocks behind, is secretly on stagenet, or blocks the browser wallet you're building because it doesn't send CORS headers.

So it's worth checking nodes properly. Below is a small checker you can run yourself against a few hundred public nodes.

By the end you'll have a script that:

asks each node for its status over RPC
works the same for clearnet and .onion nodes
spots nodes that say they're synced but aren't
checks whether a browser can call the node (CORS)
keeps a 24-hour uptime score in SQLite

  1. What a public node tells you

Public nodes run monerod with --restricted-rpc, which still allows get_info. One POST to /json_rpc gives you almost everything:

bash
curl -s http://node.example.com:18089/json_rpc \
-d '{"jsonrpc":"2.0","id":"0","method":"get_info"}' \
-H 'Content-Type: application/json'

The fields that matter for a health check:

Field Why you care
status Should be "OK"
height Current block height the node knows about
synchronized The node's own opinion on whether it's caught up
nettype "mainnet", "stagenet" or "testnet". You'd be surprised how many public lists include stagenet nodes

  1. Get a list of nodes to check

You can type in your own, or start from a public list. XMRList publishes the nodes it tracks as JSON, so I'll use that as the seed:

python
import requests

SEED = "https://xmrlist.com/tools/nodes.json"

def load_nodes():
data = requests.get(SEED, timeout=20).json()
return [n["url"] for n in data["nodes"]]

Each URL looks like http://host:18089 or http://xxxx.onion:18089.

  1. Check one node (clearnet or Tor)

For onion nodes you need Tor running locally (apt install tor) and SOCKS support in requests (pip install "requests[socks]"). Use socks5h, not socks5, so the .onion name is resolved inside Tor instead of by your local DNS.

python
import time

TOR = {"http": "socks5h://127.0.0.1:9050", "https": "socks5h://127.0.0.1:9050"}
RPC = {"jsonrpc": "2.0", "id": "0", "method": "get_info"}

def check(url):
onion = ".onion" in url
t0 = time.monotonic()
try:
r = requests.post(
url.rstrip("/") + "/json_rpc",
json=RPC,
headers={"Origin": "https://example.com"}, # needed to see CORS headers
proxies=TOR if onion else None,
timeout=60 if onion else 10,
)
info = r.json()["result"]
return {
"url": url,
"ok": info.get("status") == "OK",
"height": info.get("height", 0),
"synced": bool(info.get("synchronized")),
"mainnet": info.get("nettype") == "mainnet",
"cors": r.headers.get("Access-Control-Allow-Origin") in ("*", "https://example.com"),
"ms": round((time.monotonic() - t0) * 1000),
}
except Exception as e:
return {"url": url, "ok": False, "error": type(e).name}

Two details that cost me time:

CORS headers only show up if you send an Origin header. Without it every node looks like it has CORS disabled.
Tor needs a much longer timeout. Ten seconds is fine for clearnet; onion circuits regularly take 20–40 seconds on the first request.

  1. Don't trust synchronized

A node can report synchronized: true and still be behind. It might have lost peers, or it might be a node someone forgot about months ago. So compare every node against the rest of the network instead of against itself.

Taking the maximum height is a bad idea, because one broken or malicious node reporting a huge number would make everyone else look stale. The median of healthy mainnet nodes is much harder to game:

python
from statistics import median_high

def mark_fresh(results, max_lag=2):
heights = [r["height"] for r in results if r.get("ok") and r.get("mainnet")]
tip = median_high(heights) if heights else 0
for r in results:
r["fresh"] = bool(
r.get("ok") and r.get("mainnet") and r.get("synced")
and tip - r.get("height", 0) <= max_lag
)
return tip

Monero targets a block every two minutes, so a lag of two blocks gives a node a few minutes of slack without hiding a stuck one.

  1. Check them all in parallel

Onion checks are slow, so run them concurrently:

python
from concurrent.futures import ThreadPoolExecutor

def run():
urls = load_nodes()
with ThreadPoolExecutor(max_workers=32) as pool:
results = list(pool.map(check, urls))
tip = mark_fresh(results)
return tip, results

With 32 workers, about 300 nodes take a minute or two, mostly spent waiting on Tor.

  1. Turn snapshots into uptime

One check tells you if a node is up right now. To know whether it's reliable you need history. SQLite is plenty:

python
import sqlite3

db = sqlite3.connect("nodes.db")
db.execute("create table if not exists checks (url text, ts integer, fresh integer, height integer)")

def save(results):
now = int(time.time())
db.executemany(
"insert into checks values (?, ?, ?, ?)",
[(r["url"], now, int(r.get("fresh", False)), r.get("height", 0)) for r in results],
)
db.commit()

def uptime_24h():
since = int(time.time()) - 86400
rows = db.execute(
"select url, round(avg(fresh) * 100, 1) from checks where ts > ? group by url order by 2 desc",
(since,),
)
return rows.fetchall()

Note that "up" here means fresh: answering, on mainnet, and within two blocks of the network. A node that answers but is 300 blocks behind counts as down, because for a wallet it effectively is.

  1. Put it together and schedule it python if name == "main": tip, results = run() save(results) good = [r for r in results if r.get("fresh")] print(f"tip {tip}: {len(good)}/{len(results)} nodes fresh") for url, pct in uptime_24h()[:10]: print(f"{pct:5.1f}% {url}")

Run it every 10 minutes from cron:

*/10 * * * * cd /opt/nodecheck && /usr/bin/python3 check.py >> check.log 2>&1
Things I'd add next
Check from more than one region. A node that's blocked from one country isn't necessarily down. Running the same script from two VPSes and only marking a node down when both fail removes most false alarms.
Flag self-signed TLS separately. Plenty of https:// nodes use self-signed certificates. That's fine for a CLI wallet but breaks browsers, so store it as its own field instead of a failure.
Don't hammer anyone. Every 10 minutes is plenty. These are volunteers' machines.

If you'd rather not run this yourself, XMRList keeps a live version at xmrlist.com/tools/nodes, and its JSON feed is free to use in your own wallet or app. And if you run a public node, thank you: the network is better for it.

Top comments (0)