Networking & peers¶
UMNode joins a chain's peer-to-peer network to fetch data. It is deliberately outbound-only: it dials peers and runs discovery, but never accepts inbound connections, so no listening port needs to be opened.
Connection lifecycle¶
sequenceDiagram
participant U as UMNode
participant DHT as Discovery (v4/v5)
participant P as Peer
U->>DHT: bootstrap from BootstrapNodes
DHT-->>U: candidate enodes
U->>P: dial out (TCP)
U->>P: eth handshake (Status)
Note over U,P: validate network id + EIP-2124 fork id
alt compatible
P-->>U: handshake OK
U->>U: record in peer inventory
U->>P: poll chain head (head-poll-every)
else incompatible
U->>P: disconnect
end
Discovery¶
- Discovery v4 and v5 are both supported (
--discovery-v4,--discovery-v5). BootstrapNodesfrom the config seed the Kademlia DHT — they are not kept as peers, only used to find others.--discovery-addris the UDP bind for discovery (default0.0.0.0:30303). There is no TCP listener.- DNS discovery (EIP-1459) is a
second source, on by default (
--dns-discovery): the node reads the network's signed node list from DNS — go-ethereum'sall.<network>.ethdisco.nettree for mainnet, Sepolia and Hoodi, or theenrtree://URLs a network'sDNSDiscoveryconfig names — and dials those nodes alongside the DHT's. The tree is re-read as it changes, so peers are still found once the bootnodes have gone away: in October 2026 all five bundled Sepolia bootnodes had stopped answering and a fresh node sat without a single peer until this landed. Both sources feed one mix the dialer draws from in turn, so a slow DNS resolution never holds back the DHT. An emptyDNSDiscoverylist turns it off for a network; a chain with no published tree (BNB Smart Chain, Ephemery) has none and is unaffected. --no-discoverydisables the DHT and DNS discovery; UMNode then relies solely onStaticNodes/TrustedNodes, which suits a private or permissioned network.
Peer sources¶
| Source | Kept as peer? | Counts against MaxPeers? |
|---|---|---|
BootstrapNodes |
No — discovery seed only | No |
| Discovered peers (DHT) | Yes | Yes |
DNS node lists (DNSDiscovery / the network's known tree) |
Yes | Yes |
StaticNodes |
Yes — always dialed & reconnected | Yes |
TrustedNodes |
Yes — always dialed & reconnected | No (bypass the cap) |
RPCPeers / --rpc-peers |
Not a p2p peer — a JSON-RPC endpoint used as a block source, see RPC peers | No |
--max-peers (default 30) caps the number of connected peers; trusted nodes are
exempt so a known operator endpoint is never displaced.
The eth handshake¶
After dialing, UMNode performs the eth-layer handshake (Status exchange) and
validates compatibility before keeping a peer:
- Network id must match the configured
NetworkId. - Fork id (EIP-2124, derived from
GenesisHash+ theForksschedule) must be compatible — this is why an accurate fork schedule and a correctTotalDifficultymatter; without them a peer drops UMNode right after the handshake.
Only peers that pass are kept and recorded.
On a BNB Smart Chain network the handshake follows bsc's flavours of the
protocol: eth/70 with the total difficulty kept in the status, and eth/68
followed by an UpgradeStatus exchange; eth/69 is not advertised. See
BNB Smart Chain.
Peer inventory¶
Discovery keeps its own node database (--node-db), but that is
discovery-quality bookkeeping — endpoints and ping/pong times, expired on geth's
schedule. It does not record which nodes actually completed the eth handshake
on the right network and fork.
That set — the peer inventory (--inventory-db) — is UMNode's own record of
known-good peers, so it can re-dial them after a restart instead of rediscovering
from scratch. For each peer it stores identity (node id, enode, client name,
caps), the eth-layer facts learned at handshake (protocol version, network id,
fork hash/next, remote head), and connection bookkeeping (first/last seen, times
seen, last disconnect reason, and whether it was connected at exit).
On startup UMNode re-dials only the peers that were still connected when it
last exited — not every peer ever recorded. Each record carries a connected
flag: set while the peer is up, kept through a graceful shutdown (and a hard kill,
where the disconnect is never written), but cleared when a peer drops mid-run. So
a peer that fell away hours before the restart is left to discovery, while the set
that was live at shutdown is re-dialed. UMNode hands each to the p2p dialer as a
static target (re-dialed if it drops, bounded by the dial limit), sorted
most-recently-seen first and capped at --max-peers. It logs a
Re-dialing known-good peers from inventory summary at start (per-peer detail at
debug level). Discovery still runs in parallel, and stale/offline entries simply
fail to dial.
- It is keyed by node id and safe for concurrent use (one goroutine per peer writes to it).
- The re-dial is best-effort and additive to discovery; on a first run (empty inventory) or when no peer was connected at last exit, it is a logged no-op and discovery takes over.
- Set
--inventory-dbto empty to disable persistence (and with it, the re-dial).
Head tracking¶
UMNode polls a peer for the chain head every --head-poll-every (default 4s,
0 disables) and emits a new-head event whenever the head advances. Configured
RPC peers report their latest block into the same tracker on the
same interval (4 s when polling is off), so the head also moves when an endpoint
sees a block before the polled peer does — or when there are no wire peers at
all, in which case the realtime worker follows the endpoints' heads. When the
realtime worker is enabled, these events drive it to fetch
and store the new blocks; it logs each one (Imported block) as it lands.
The poller sticks to one peer, asking it for the headers just past the known head. If that peer goes 30 s without a new header — it is behind, or does not serve the head, and answers every poll with an empty list — the poller switches to another ready peer (it keeps the peer when it is the only one). Before 0.2.122 it only switched when the peer disconnected, so a single lagging peer could freeze the head and realtime sync with it.
Pulling block data¶
Ready peers form a shared peer set. The head poller picks one peer from it to track the head, while the block fetcher spreads its header, body and (when fetched rather than derived) receipt requests across all ready peers and retries a failed request on a different peer — so load is balanced and no single peer is a bottleneck. Configured RPC peers are tried first; the wire peers take whatever they cannot serve. Receipts use the eth/69+ encoding and are only requested from eth/69 and eth/70 peers; eth/68 peers still serve headers and bodies. See Realtime sync.
RPC peers¶
An RPC peer is the JSON-RPC endpoint of an existing node — a synced archive,
a node still syncing, another UMNode — used as a block source next to the wire
peers (--rpc-peers, or RPCPeers in the network config: http(s):// or
ws(s):// URLs, or IPC socket paths). It is the fast path for standing a node
up next to infrastructure you already run: a local node answers in
milliseconds, serves whole history and does not come and go the way discovered
peers do.
An RPC peer speaks the same slice of the fetcher's peer interface a wire peer does — headers by number, bodies and receipts by hash — so everything the fetcher does applies to it unchanged:
- Verification. Every header must hash to the block hash the endpoint reports for it, every body and receipts set must hash to its header's roots. An endpoint is trusted for nothing: invalid data counts as a fault (see below) and, at the fault limit, the endpoint rests for a minute instead of being re-tried.
- Same chain only. Before an endpoint is used at all it must serve this
network's genesis block (
eth_getBlockByNumber(0)); one on another chain is rejected for good, loudly. Its chain id is logged for good measure. - Preferred, with fall-through. RPC peers are tried before the wire
peers for every request. Whatever an endpoint lacks — a block above a
partially synced node's head (the request is clamped to the endpoint's last
known head, refreshed first when it looks stale), pruned history (the endpoint
returns
null), a client withouteth_getBlockReceipts(remembered: that endpoint is not asked for receipts again) — simply falls through to the next candidate, so a partial node contributes what it has and the wire peers cover the rest. - Bounded load.
--rpc-peer-concurrency(default 4) requests are in flight per endpoint. The fetcher counts an endpoint as that many peers when it sizes a fetch, and a saturated endpoint is skipped — the overflow of a wide fetch lands on the wire peers — and waited on only when no other peer can serve the request. Bodies and receipts are asked for--rpc-peer-batch(default 16) blocks per JSON-RPC batch call, headers eight times that; lower the batch for a provider with small batch or response-size limits. The per-request--request-timeoutapplies as to any peer. - Health. Every
--head-poll-every(4 s when wire head polling is off) each endpoint is probed for its latest block, which feeds the head tracker and bounds the requests sent to it. A failed probe or request — connection refused, a non-2xx HTTP status such as a provider's429, a timeout — rests the endpoint with a backoff that doubles from 5 s to 2 min per consecutive failure; it is probed again when the backoff elapses and returns to service on the first success. - Visibility.
debug_listPeerslists each endpoint with its state (ready,downwith the time to the next probe,unverified,rejected), head, request and failure counts and last error;debug_statuscounts them; theumnode_rpcpeers_readygauge exports how many are serving. Endpoints are identified by scheme and host only — the path, query and credentials, where an API key usually lives, never appear in logs or RPC output.
A network can run from RPC peers alone: with no p2p nodes configured and
--no-discovery, the endpoints supply the head and every block, and no p2p
port is used at all. debug_getP2PBlockArtifact stays a wire diagnostic and
never reports an RPC peer.
Dropping faulty peers¶
Every fetched block is validated against its header — headers must be in order, and a body and receipts set must hash to the header's transactions, uncles, withdrawals and receipts roots — so a peer can't slip past bad data.
Decoding is tolerant first: a body that isn't the canonical
[transactions, uncles, withdrawals?] triple — some peers send a shorter list,
even an empty one, for an empty block — is recovered field-by-field (missing tail
defaulted to empty) rather than rejected outright, then validated against the
header like any other. A recoverable quirk therefore costs nothing, and because
the recovered body is still checked against the header's roots, this never accepts
wrong data. Only a response that can't be decoded at all, or whose recovered
data fails validation, is counted as a fault; once a peer reaches a small
limit it is disconnected (--drop-faulty-peers, on by default) and refused
on reconnect for ten minutes — otherwise a persistently-bad peer redials within
seconds, re-faults, and is dropped again, endlessly. The ban expires so the peer
is re-evaluated later (it may serve other ranges fine). This matters most during
backfill: without it, one genuinely broken peer — serving receipts that don't
match the header, say — is re-tried indefinitely and can stall the whole fill.
Honest peers never fault. Set --drop-faulty-peers=false to keep faulty peers
connected (faults are still logged, but no one is dropped or banned).
Filtering peers up front (opt-in)¶
Two further gates pre-filter which peers a request goes to. Both are off by default, on purpose: a node that only ever reads from peers benefits from staying connected to as many as possible, and neither gate catches the failures above (a peer that handshakes cleanly then serves garbage) — that's what fault-dropping is for. Turn them on for a stricter peer set.
--require-fork-matchrejects a peer at the handshake unless its EIP-2124 fork hash matches ours. By default UMNode validates only NetworkID and genesis — the facts that mean "different chain" — and tolerates peers a fork ahead or behind, since they still serve the historical data we want. This gate trades some of those peers for certainty that everyone is on our exact fork.--require-served-rangeonly asks an eth/69+ peer for a block its advertised range[earliest, latest]covers, skipping peers that have pruned that far back (or haven't synced that far up). It's the peer's handshake snapshot, so it's most useful as a lower bound — "does this peer reach far enough back into history?" — which is exactly the question when backfilling early blocks. eth/68 peers advertise no range and are always kept, so this never filters them.
See Configuration for the per-network equivalents of these settings.