Skip to main content

Peer Discovery

Connect to a Peer assumed you already had an enode:// URL. In practice, a node starting cold has none — it needs to find other nodes on the network before it can dial anyone. This guide covers the three discovery mechanisms Nethereum implements — discv4 (the original Kademlia UDP protocol), discv5 (its authenticated successor), and DNS ENR-tree lookup (EIP-1459, for bootstrapping from a small number of well-known DNS names) — plus two supporting pieces every long-running node needs: a stable node identity and a memory of which peers were worth reconnecting to.

Prerequisites​

dotnet add package Nethereum.DevP2P

You'll want a persisted node identity (below) rather than the throwaway EthECKey.GenerateKey() used in the previous guide — discovery is precisely the mechanism by which other nodes learn your enode:// and remember you, so a node-id that changes every restart defeats the point.

Persist your node identity​

A stable enode across restarts is what lets a remote peer durably trust your node by node-id — bond once, reconnect for weeks. NodeKeyStore.LoadOrCreate loads a raw 32-byte private key from path, or generates and saves one on first run:

using Nethereum.DevP2P.NodeDb;

// EthECKey LoadOrCreate(string path, Action<string> log = null)
var localKey = NodeKeyStore.LoadOrCreate("nodekey");

Run this once at node startup instead of EthECKey.GenerateKey(). The first run creates nodekey on disk; every subsequent run loads the same key, so your node's identity (and any reputation peers have built for it) survives restarts.

Source: src/Nethereum.DevP2P/README.md — "Every example needs a node identity key…".

Mental model: three ways to find a node​

MechanismTransportUse when
discv4Unencrypted, signed UDP (Kademlia)Talking to older/simpler clients; bonding with a known seed list
discv5Authenticated UDP (WHOAREYOU + HKDF session keys, AES-GCM)Mainnet-style discovery; the modern default with TalkReq/TalkResp extensibility
DNS ENR-tree (EIP-1459)DNS TXT recordsBootstrapping from a small number of trusted, well-known hostnames rather than hardcoded IPs

All three ultimately hand you the same thing: a list of enode (or ENR) addresses to feed into StaticPeerConnector/RlpxConnection from the previous guide. They differ in how much trust and infrastructure they assume up front.

Discover peers with discv5 (the one-line quickstart)​

Discv5Discovery.StartMainnet is the fastest way to get a discv5 node walking the network: it builds the listener, signs a local ENR, seeds the mainnet bootnodes, starts the routing-table walk, and hands back both objects — you own teardown of each:

using Nethereum.DevP2P.Discv5;
using Nethereum.Signer;

var localKey = EthECKey.GenerateKey();

var (discovery, listener) = Discv5Discovery.StartMainnet(
localKey,
enqueueEnode: enode => Console.WriteLine($"discovered {enode}")); // feed your dial loop here

// ... on shutdown — the service does NOT dispose the listener it walks:
await discovery.StopAsync();
await listener.DisposeAsync();

Every enode string handed to enqueueEnode is a live candidate — pass it straight into StaticPeerConnector.ConnectAsync from Connect to a Peer, or queue it for your own dial scheduler. Note the two-step shutdown: Discv5Discovery stops the periodic walk, but it doesn't own the listener's socket, so you dispose that separately.

The full signature exposes tuning most callers don't need day one — a custom bind address/port, a logger, and a pre-dial fork-ID filter so you never bother dialing peers on the wrong chain:

(Discv5PeerDiscoveryService Discovery, Discv5Listener Listener) StartMainnet(
EthECKey localKey,
Action<string> enqueueEnode,
IPAddress bindAddress = null,
int udpPort = 0,
Action<string> log = null,
Func<byte[], bool> ethForkIdFilter = null,
CancellationToken ct = default)

Source: src/Nethereum.DevP2P/README.md — "Discover peers — Discv5Discovery.StartMainnet(localKey, enqueueEnode)".

See the underlying protocol: discv4 ping/pong​

Discv5Discovery is a convenience wrapper; underneath, discovery is a signed UDP request/response protocol. This is easiest to see with the older discv4 protocol, whose ping/pong bonding is exercised directly by a test — two Discv4Listeners on loopback, each auto-responding to the other's messages:

var keyA = EthECKey.GenerateKey();
var keyB = EthECKey.GenerateKey();

var tableA = new Discv4RoutingTable(keyA.GetPubKeyNoPrefix());
var tableB = new Discv4RoutingTable(keyB.GetPubKeyNoPrefix());

using var nodeA = new Discv4Listener(keyA, tableA);
using var nodeB = new Discv4Listener(keyB, tableB);

nodeA.Start(udpPort: 0, bindAddress: IPAddress.Loopback);
nodeB.Start(udpPort: 0, bindAddress: IPAddress.Loopback);

nodeB.PingReceived += async (_, e) =>
{
var pong = new Discv4PongMessage
{
To = new Discv4Endpoint { IP = e.Sender.Address, UdpPort = (ushort)e.Sender.Port, TcpPort = 0 },
PingHash = e.PingHash,
Expiration = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds()
};
await nodeB.SendPongAsync(e.Sender, pong);
};

var ping = new Discv4PingMessage
{
Version = 4,
From = new Discv4Endpoint { IP = IPAddress.Loopback, UdpPort = (ushort)nodeA.Port, TcpPort = 30303 },
To = new Discv4Endpoint { IP = IPAddress.Loopback, UdpPort = (ushort)nodeB.Port, TcpPort = 0 },
Expiration = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds()
};
await nodeA.SendPingAsync(new IPEndPoint(IPAddress.Loopback, nodeB.Port), ping);

// After the round trip, both routing tables have learned about each other:
// tableA.Count >= 1 and tableB.Count >= 1

This is the discv4 endpoint-proof bonding step: a node only adds a peer to its Kademlia routing table after a successful ping/pong round trip, which is what makes discv4 resistant to naive amplification/spoofing attacks. Discv4Listener has AutoRespond = true by default, so in practice you rarely wire the PingReceived handler yourself — the harvester below does it for you.

Source: tests/Nethereum.DevP2P.SpecTests/Discv4/Discv4ListenerTests.cs:16 — TwoListeners_PingPongRoundTrip_BothLearnEachOther, tagged [NethereumDocExample(DocSection.DevP2P, "devp2p", "discv4 ping/pong over loopback")] (adapted: Assert.* calls on the received values are omitted for readability — the wire calls are unchanged).

The high-level discv4 harvester​

For day-to-day use you don't drive ping/pong by hand — PeerDiscoveryService.DiscoverAsync bonds with a list of seed enodes and returns the flat list of enodes it discovered:

Task<List<string>> DiscoverAsync(
IEnumerable<string> seedEnodes, TimeSpan perSeedTimeout, CancellationToken ct)

Feed it your bootnodes (or a previously-discovered peer list — see the persistent cache below) and it returns candidates ready for StaticPeerConnector.

Source: src/Nethereum.DevP2P/README.md — "Other discovery paths — discv4".

Follow-up (untested example): the README documents PeerDiscoveryService.DiscoverAsync's signature, but no [NethereumDocExample]-tagged test exercises the harvester end-to-end (only the lower-level Discv4Listener ping/pong is tagged). Treat the signature as verified, but a full runnable harvester example is not yet backed by a tagged test.

DNS ENR-tree discovery (EIP-1459)​

Some networks publish their bootstrap peer list as a DNS TXT-record tree rather than a hardcoded list — this is how go-ethereum distributes its mainnet bootnodes, for example. EnrTreeResolver.ResolveAsync walks an enrtree:// URL into a flat list of enode URLs:

Task<List<string>> ResolveAsync(
string enrtreeUrl, TimeSpan timeout, int maxLeaves, CancellationToken ct)

EnrTreeResolver falls back from OS DNS to a public resolver if needed, and Nethereum ships the mainnet all/snap/les tree URLs baked in, so you rarely need to source one yourself. Use this when you want to bootstrap a node with zero hardcoded IPs — just the tree's root name.

Source: src/Nethereum.DevP2P/README.md — "Other discovery paths — DNS ENR-tree (EIP-1459)" and "Discovery" (EnrTreeResolver).

Node identity concepts​

Two different identifiers are in play across discv4 and discv5, and mixing them up is a common source of confusion:

  • enode id (discv4) — the raw 64-byte secp256k1 public key.
  • discv5 node id — keccak256(pubkey), a further hash of that same key.

ENR records (EnrRecord, from Nethereum.Model.Enr) carry a node's discv5 identity and are set on a listener via LocalEnrEncoded / LocalEnrSequence.

Source: src/Nethereum.DevP2P/README.md — "Discovery" (closing paragraph).

Remember which peers were worth reconnecting to​

Discovery finds candidates; it doesn't know which of them turned out to be reliable. PersistentPeerCache records dial success/failure per peer and returns your preferred enodes across restarts, so a node doesn't have to rediscover its whole working peer set from scratch every time it starts:

Source: src/Nethereum.DevP2P/README.md — "Peering & DoS hardening — PersistentPeerCache".

Follow-up (untested example): PersistentPeerCache is documented in the README as a bullet point (recording dial success/failure, returning preferred enodes) but has no method signature block in the README and no [NethereumDocExample]-tagged test in tests/Nethereum.DevP2P.*. Its role is verified from the README prose only — a concrete usage example is a follow-up.

Common mistakes​

SymptomCauseFix
Discv5Discovery.StartMainnet never calls enqueueEnodeBound to an address/port that's unreachable from the internet (e.g. behind strict NAT with no port forwarding), or outbound UDP blockedConfirm the UDP port is reachable; check firewall/NAT
discv4 PingReceived never fires in your own listener codeAutoRespond already handled it — you don't usually need to also handle PingReceived yourself unless customizing bondingOnly hook PingReceived/PongReceived for diagnostics or custom logic
Node's peer reputation resets every deployUsing EthECKey.GenerateKey() at startup instead of NodeKeyStore.LoadOrCreatePersist the node key; see "Persist your node identity" above
DNS ENR-tree resolution times outtimeout/maxLeaves too small for the tree's depth, or OS DNS is filtering TXT recordsIncrease timeout; EnrTreeResolver falls back to a public resolver automatically

Decision guidance​

I want to…Use
Get discovering with the least setup (mainnet-style)Discv5Discovery.StartMainnet
Match an older/simpler peer set, or need fine control over bondingDiscv4Listener + PeerDiscoveryService.DiscoverAsync
Bootstrap with zero hardcoded IPsEnrTreeResolver.ResolveAsync against a known enrtree:// root
Keep working peers across restartsPersistentPeerCache
Keep the same node-id across restartsNodeKeyStore.LoadOrCreate

Full API reference (routing tables, rate limiting, ENR record shape): see the Nethereum.DevP2P reference.

Next steps​

  • Run a Node — feed discovered enodes into the SyncNode facade's peer pool and start serving/syncing.
  • Connect to a Peer — revisit dialing once discovery gives you addresses, including capability negotiation and fork-compatibility gotchas.
  • Full Sync — once peers are connected, this covers pulling the block archive from them.