<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: xmrlist</title>
    <description>The latest articles on DEV Community by xmrlist (@xmrlist).</description>
    <link>https://dev.to/xmrlist</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4235588%2F14721dc0-c366-4598-8b03-b74b017de876.png</url>
      <title>DEV Community: xmrlist</title>
      <link>https://dev.to/xmrlist</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xmrlist"/>
    <language>en</language>
    <item>
      <title>How to health-check Monero remote nodes (sync, Tor, CORS and uptime) in about 80 lines of Python</title>
      <dc:creator>xmrlist</dc:creator>
      <pubDate>Mon, 09 Nov 2026 17:13:40 +0000</pubDate>
      <link>https://dev.to/xmrlist/how-to-health-check-monero-remote-nodes-sync-tor-cors-and-uptime-in-about-80-lines-of-python-cgh</link>
      <guid>https://dev.to/xmrlist/how-to-health-check-monero-remote-nodes-sync-tor-cors-and-uptime-in-about-80-lines-of-python-cgh</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So it's worth checking nodes properly. Below is a small checker you can run yourself against a few hundred public nodes.&lt;/p&gt;

&lt;p&gt;By the end you'll have a script that:&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;What a public node tells you&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;bash&lt;br&gt;
curl -s &lt;a href="http://node.example.com:18089/json_rpc" rel="noopener noreferrer"&gt;http://node.example.com:18089/json_rpc&lt;/a&gt; \&lt;br&gt;
  -d '{"jsonrpc":"2.0","id":"0","method":"get_info"}' \&lt;br&gt;
  -H 'Content-Type: application/json'&lt;/p&gt;

&lt;p&gt;The fields that matter for a health check:&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Get a list of nodes to check&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import requests&lt;/p&gt;

&lt;p&gt;SEED = "&lt;a href="https://xmrlist.com/tools/nodes.json" rel="noopener noreferrer"&gt;https://xmrlist.com/tools/nodes.json&lt;/a&gt;"&lt;/p&gt;

&lt;p&gt;def load_nodes():&lt;br&gt;
    data = requests.get(SEED, timeout=20).json()&lt;br&gt;
    return [n["url"] for n in data["nodes"]]&lt;/p&gt;

&lt;p&gt;Each URL looks like &lt;a href="http://host:18089" rel="noopener noreferrer"&gt;http://host:18089&lt;/a&gt; or &lt;a href="http://xxxx.onion:18089" rel="noopener noreferrer"&gt;http://xxxx.onion:18089&lt;/a&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check one node (clearnet or Tor)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
import time&lt;/p&gt;

&lt;p&gt;TOR = {"http": "socks5h://127.0.0.1:9050", "https": "socks5h://127.0.0.1:9050"}&lt;br&gt;
RPC = {"jsonrpc": "2.0", "id": "0", "method": "get_info"}&lt;/p&gt;

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

&lt;p&gt;Two details that cost me time:&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Don't trust synchronized&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from statistics import median_high&lt;/p&gt;

&lt;p&gt;def mark_fresh(results, max_lag=2):&lt;br&gt;
    heights = [r["height"] for r in results if r.get("ok") and r.get("mainnet")]&lt;br&gt;
    tip = median_high(heights) if heights else 0&lt;br&gt;
    for r in results:&lt;br&gt;
        r["fresh"] = bool(&lt;br&gt;
            r.get("ok") and r.get("mainnet") and r.get("synced")&lt;br&gt;
            and tip - r.get("height", 0) &amp;lt;= max_lag&lt;br&gt;
        )&lt;br&gt;
    return tip&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check them all in parallel&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Onion checks are slow, so run them concurrently:&lt;/p&gt;

&lt;p&gt;python&lt;br&gt;
from concurrent.futures import ThreadPoolExecutor&lt;/p&gt;

&lt;p&gt;def run():&lt;br&gt;
    urls = load_nodes()&lt;br&gt;
    with ThreadPoolExecutor(max_workers=32) as pool:&lt;br&gt;
        results = list(pool.map(check, urls))&lt;br&gt;
    tip = mark_fresh(results)&lt;br&gt;
    return tip, results&lt;/p&gt;

&lt;p&gt;With 32 workers, about 300 nodes take a minute or two, mostly spent waiting on Tor.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turn snapshots into uptime&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;python&lt;br&gt;
import sqlite3&lt;/p&gt;

&lt;p&gt;db = sqlite3.connect("nodes.db")&lt;br&gt;
db.execute("create table if not exists checks (url text, ts integer, fresh integer, height integer)")&lt;/p&gt;

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

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

&lt;p&gt;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.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Put it together and schedule it
python
if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":
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}")&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run it every 10 minutes from cron:&lt;/p&gt;

&lt;p&gt;*/10 * * * * cd /opt/nodecheck &amp;amp;&amp;amp; /usr/bin/python3 check.py &amp;gt;&amp;gt; check.log 2&amp;gt;&amp;amp;1&lt;br&gt;
Things I'd add next&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
Don't hammer anyone. Every 10 minutes is plenty. These are volunteers' machines.&lt;/p&gt;

&lt;p&gt;If you'd rather not run this yourself, XMRList keeps a live version at &lt;a href="https://xmrlist.com/tools/nodes" rel="noopener noreferrer"&gt;xmrlist.com/tools/nodes&lt;/a&gt;, 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.&lt;/p&gt;

</description>
      <category>monero</category>
      <category>python</category>
      <category>privacy</category>
      <category>cryptocurrency</category>
    </item>
  </channel>
</rss>
