---
# System prepended metadata

title: Monero Nebula Analysis

---

# Monero Nebula Analysis

## 2026-02-06

My questions:

1. What is counted by monero.fail/map? Is it also unique IP:Port? What's the aggregation time window?
2. How are spy nodes identified? Is there a methodology written up somewhere or is that confidential?
3. The crawler is performing a handshake with every peer it discovers. What information can I extract from the handshake that is worth capturing?
4. Is there potentially another API exposed on Monero nodes which the crawler could call to get even more information?
5. Usually 250 nodes are shared in the handshake. Does a node maintain a bigger list internally and I'm served a random sample of 250 nodes of a larger set? Would it make sense to connect repeatedly to get more nodes?

I've got in touch with the researches from Monero in the Matrix "Monero Research Lab" channel. I was pointed to the following links:

- https://github.com/lalanza808/monero.fail
- https://github.com/Boog900/p2p-proxy-checker?tab=readme-ov-file#how-this-works
- https://github.com/Rucknium/xmrnetscan/tree/main/src/rust
- https://github.com/ykpyck/monero-traffic-analysis/
- https://arxiv.org/abs/2509.10214
- https://docs.getmonero.org/rpc-library/monerod-rpc/#get_info
- https://github.com/Boog900/monero-ban-list
- https://github.com/Rucknium/misc-research/blob/main/Monero-Peer-Subnet-Deduplication/pdf/monero-peer-subnet-deduplication.pdf
- https://github.com/monero-project/monero/pull/9939
- https://github.com/monero-project/meta/issues/1124


## 2026-02-09


### `monero.fail`

I had a read of the [monero.fail repository](https://github.com/lalanza808/monero.fail) and if I understand correctly this works by people registering their node proactively with that page. Then that page periodically performs handshakes with these nodes to bootstrap further crawls and then to get even more peers. Then, [every 5 minutes](https://github.com/lalanza808/monero.fail/blob/09882be1ee398b8e9a3acc97cfe9ca1f0baacbe9/crontab#L4), [20 peers are taken from the database](https://github.com/lalanza808/monero.fail/blob/09882be1ee398b8e9a3acc97cfe9ca1f0baacbe9/xmrnodes/cli.py#L142) that haven't been probed for the longest time to perform a handshake and continue to populate the database. If one of these 20 peers does not respond, the peer is marked as "dead" and then deleted.

The number of "Total Peers" on [the map page](https://monero.fail/map) which currently shows ~17k peers shows all peers that were discovered in the above process.

**Comments**
- Only checking 20 peers every 5 minutes sounds way too little to compensate for network dynamicity.
- This is basically counting the same as I do (unique peer/port).

### Spy Node Detection

If I understand correctly [this is the spy-node condition](https://github.com/Boog900/p2p-proxy-checker/blob/b295110e9be4caf9d468d2bbdebaffa83bf49eb8/src/main.rs#L170C1-L172C45):

```rust
    let bad = client.info.basic_node_data.peer_id != ping.peer_id
        || ping.peer_id != ping_2.peer_id
        || ping_2.peer_id != ping_3.peer_id;
```

**Comments:**

- Is it really necessary to perform multiple pings or would the peer id that's returned by the first ping already always be different if it's a "bad" node?

If a single ping suffices I can minimize the roundtrips to the remote node and make the crawls as efficient as possible.


### Nebula

I've implemented the following sequence when crawling a peer:

1. establish a TCP connection
2. perform Handshake
3. perform Ping
4. call the get_info RPC API (if advertised in the handshake)

I'm starting the crawl from the following [set of nodes](https://github.com/monero-project/monero/blob/d0d418483514d3caf79547e302937e45878eab21/src/p2p/net_node.inl#L752):

```
176.9.0.187:18080
88.198.163.90:18080
192.99.8.110:18080
37.187.74.171:18080
88.99.195.15:18080
5.104.84.64:18080
```


#### First Results

General numbers:

- Identified peers in the network: `24,571`
- Peers with successful TCP connection: `12,544`
- Peers with successful Handshake: `8,519` (all in the network with ID: `EjDxcWEEQWEXMQCCFqGhEA==`)
- Peers responding to RPC get_info: `585`
- Peers where the Handshake peer ID is different from the ping peer ID: `5,713`
- Peers where the Handshake peer ID is equal to the ping peer ID: `1,658`
  ! Both numbers, 5,713 and 1,658 don't add up to 8,519, because sometimes the ping failed.
- Peers with a non-empty `pruning_seed`: `1,026`

#### Data sample

```json
{
  "PeerID": "14LFVfFLY2h9F8tUvSYtw83K3aX", // IP:Port encoded as a libp2p Multihash (for compatibility reasons)
  "Maddrs": [
    "/ip4/34.142.196.8/tcp/18080"
  ],
  "FilteredMaddrs": null,
  "ListenMaddrs": [
    "/ip4/34.142.196.8/tcp/18080"
  ],
  "ConnectMaddr": "/ip4/34.142.196.8/tcp/18080",
  "Protocols": null,
  "AgentVersion": "",
  "ConnectDuration": "1.345750791s", // TCP Connection
  "CrawlDuration": "2.298663417s", // Full crawl (TCP, handshake, ping, JSON RPC)
  "VisitStartedAt": "2026-02-09T14:41:41.954487+01:00",
  "VisitEndedAt": "2026-02-09T14:41:44.253164+01:00",
  "ConnectErrorStr": "",
  "CrawlErrorStr": "",
  "Properties": {
    "discovery_peer": { // the peer that was shared from another node
      "ip": "34.142.196.8",
      "port": 18080,
      "addr_type": 2
    },
    "handshake_duration": "376.481958ms",
    "handshake_node": {
      "id": 9478828602424419779,
      "rpc_port": 18081,
      "current_height": 3606293,
      "top_version": 16,
      "network_id": "EjDxcWEEQWEXMQCCFqGhEA==",
      "support_flags": 1,
      "my_port": 18080,
      "top_id": "9WoY6DiZIQVx+RpojApmcP3K6IWtULOv4Amv4g0ts/I=",
      "cumulative_difficulty": 584727354617670972,
      "pruning_seed": 391
    },
    "ping": {
      "peer_id": 9478828602424419779,
      "status": "OK"
    },
    "ping_duration": "186.068792ms",
    "rpc_duration": "390.052375ms",
    "rpc_info": {
      "adjusted_time": 1770644554,
      "alt_blocks_count": 0,
      "block_size_limit": 600000,
      "block_size_median": 300000,
      "block_weight_limit": 600000,
      "block_weight_median": 300000,
      "bootstrap_daemon_address": "",
      "busy_syncing": false,
      "cumulative_difficulty": 584727354617670972,
      "cumulative_difficulty_top64": 0,
      "database_size": 112742891520,
      "difficulty": 624837295794,
      "difficulty_top64": 0,
      "free_space": 18446744073709551615,
      "grey_peerlist_size": 0,
      "height": 3606293,
      "height_without_bootstrap": 0,
      "incoming_connections_count": 0,
      "mainnet": true,
      "nettype": "mainnet",
      "offline": false,
      "outgoing_connections_count": 0,
      "rpc_connections_count": 0,
      "stagenet": false,
      "start_time": 0,
      "synchronized": true,
      "target": 120,
      "target_height": 0,
      "testnet": false,
      "top_block_hash": "f56a18e83899210571f91a688c0a6670fdcae885ad50b3afe009afe20d2db3f2",
      "tx_count": 58011222,
      "tx_pool_size": 31,
      "update_available": false,
      "version": "",
      "was_bootstrap_ever_used": false,
      "white_peerlist_size": 0,
      "wide_cumulative_difficulty": "0x81d5e6e5670ad3c",
      "wide_difficulty": "0x917b347eb2",
      "status": "OK",
      "untrusted": false
    }
  }
}
```