コンテンツにスキップ

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).
  • BootstrapNodes from the config seed the Kademlia DHT — they are not kept as peers, only used to find others.
  • --discovery-addr is the UDP bind for discovery (default 0.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's all.<network>.ethdisco.net tree for mainnet, Sepolia and Hoodi, or the enrtree:// URLs a network's DNSDiscovery config 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 empty DNSDiscovery list turns it off for a network; a chain with no published tree (BNB Smart Chain, Ephemery) has none and is unaffected.
  • --no-discovery disables the DHT and DNS discovery; UMNode then relies solely on StaticNodes / 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 + the Forks schedule) must be compatible — this is why an accurate fork schedule and a correct TotalDifficulty matter; 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-db to 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 without eth_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-timeout applies 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's 429, 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_listPeers lists each endpoint with its state (ready, down with the time to the next probe, unverified, rejected), head, request and failure counts and last error; debug_status counts them; the umnode_rpcpeers_ready gauge 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-match rejects 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-range only 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.