logo
Go-Ethereum — P2P & Peer Discovery

Go-Ethereum — P2P & Peer Discovery

Architecture

1. Overview

The Go-Ethereum P2P subsystem is the networking foundation that enables a node to participate in the Ethereum network. Its primary purpose is to establish and maintain a robust set of peer connections, providing the substrate for higher-level protocols (e.g., eth, les, snap) to exchange application-specific messages such as transactions, blocks, and state information.

The subsystem is responsible for four core functions:

  1. Peer Discovery: Locating and identifying other Ethereum nodes on the network using a Kademlia-like Distributed Hash Table (DHT).
  2. Session Establishment: Initiating and accepting encrypted TCP connections with discovered peers.
  3. Message Routing: Multiplexing and de-multiplexing messages for various subprotocols over a single peer connection.
  4. Peer Management: Maintaining peer sets, handling disconnects, and protecting against malicious or malfunctioning nodes.

It integrates with the node.Node layer, which orchestrates the startup sequence and registers the various subprotocols that will run on top of the P2P server.

2. Boot & Connection Sequence

The P2P server is a critical lifecycle component started by the node.Node. Its startup sequence initializes both the discovery and connection management subsystems.

Node.Start()
   ├── p2p.Server.Start()
   │       ├── init Discovery (UDP)
   │       ├── start Dialer (outbound)
   │       ├── start Listener (inbound)
   │       └── run Peer loop
   └── Protocol registration (e.g., eth)

When p2p.Server.Start() is called, it launches several key goroutines:

  • A UDP listener for the discovery protocol (discover.UDPv4).
  • A TCP listener for incoming peer connections.
  • A dialer that actively seeks outbound connections to peers found by the discovery service.

Protocol registration, such as for the eth service, typically occurs before the node is started, ensuring that the P2P server is aware of the capabilities to advertise during the handshake process.

3. Component Responsibilities

ComponentFile(s)Responsibility
p2p.Serverp2p/server.goManages the entire peer lifecycle, including dialing, listening, and message routing. Orchestrates all P2P activity.
discover.UDPv4p2p/discover/v4_udp.goImplements the v4 peer discovery protocol, handling UDP packets for Ping, Pong, FindNode, and Neighbors.
discover.Tablep2p/discover/table.goMaintains the Kademlia-style routing table of known nodes, organized into distance-based buckets.
Peerp2p/peer.goRepresents a single, live, and handshaked TCP connection to a remote peer. Manages the message read/write loops.
Protocolp2p/protocol.goDefines the structure for subprotocols, including name, version, and message code lengths, enabling protocol multiplexing.
RLPxp2p/rlpx/rlpx.goHandles the encrypted and authenticated RLPx transport layer, including the initial cryptographic handshake.

4. Lifecycle Breakdown

The peer connection lifecycle is a multi-stage process managed by p2p.Server.

  1. Initialization: The node.Node creates a p2p.Server instance, providing it with a node key and configuration. Subprotocols (like eth) are registered, adding their capability information to the server.

  2. Discovery Startup: p2p.Server.Start() initializes and starts the discover.UDPv4 service. This service binds to a UDP port and begins sending Ping packets to bootnodes to bootstrap its routing table. It periodically runs findnode cycles to refresh its buckets with new peers.

    p2p/discover/v4_udp.go (Packet Handling)

    // loop is the main packet dispatcher.
    func (t *UDPv4) loop() {
        defer t.wg.Done()
        for {
            select {
            case <-t.closing:
                return
            case p := <-t.read:
                t.handlePacket(p.addr, p.data)
            // ...
            }
        }
    }
    
  3. Peer Selection & Dialing: The p2p.Server's run loop continuously asks the discovery table for viable nodes to connect to. It maintains a set of dialing tasks to establish outbound connections.

    p2p/server.go (Dialing Loop)

    // run is the main loop of the server.
    func (srv *Server) run() {
        // ...
        for {
            // ...
            tasks := srv.newTasks(srv.config.MaxPeers, srv.peers, time.Now())
            for _, task := range tasks {
                srv.runTask(task, &running)
            }
            // ...
        }
    }
    
  4. Handshake: When a TCP connection is established (either inbound or outbound), the RLPx handshake begins.

    p2p/rlpx/rlpx.go (Handshake)

    // Handshake performs the RLPx protocol handshake.
    func (t *Conn) Handshake(prv *ecdsa.PrivateKey) (*ecdsa.PublicKey, error) {
        // ... (auth, ack exchange)
        return t.doEncHandshake(prv, nil)
    }
    

    This involves an ECIES (Elliptic Curve Integrated Encryption Scheme) key exchange to establish a shared secret, which is then used to derive symmetric AES keys for the transport. After the crypto handshake, the p2p.Peer performs the protocol handshake by exchanging Hello messages, which contain capability information (e.g., eth/66).

  5. Message Multiplexing: Once the handshake is complete, the Peer.run() method is invoked. This starts two goroutines: readLoop and sendLoop. readLoop decodes incoming RLP-encoded messages and dispatches them to the appropriate subprotocol based on the message ID.

5. Discovery Subsystem (p2p/discover)

Go-Ethereum uses a Kademlia-like DHT for peer discovery, implemented in the p2p/discover package. This system allows nodes to find each other without relying on centralized servers.

  • Node Table: The core of the discovery service is the discover.Table, which is a routing table containing known nodes. The table is organized into logarithmic distance buckets based on the XOR distance of node IDs. This structure ensures that lookups are efficient.

  • Node Records: A node is represented by enode.Node, which contains its public key (ID), IP address, and TCP/UDP ports. These records are passed around in discovery packets.

  • Packet Flow: The discovery protocol (v4) consists of four main packet types:

    • Ping: Checks if a node is alive.
    • Pong: The response to a Ping.
    • FindNode: Requests a list of nodes close to a target ID.
    • Neighbors: The response to FindNode, containing a list of nodes.
  • Bootstrapping: A new node starts with a list of hard-coded bootnodes. It sends FindNode requests to these bootnodes to learn about other peers, recursively exploring the network until its own routing table is sufficiently populated. Static nodes can also be configured for private networks or stable peering relationships.

6. Wire Protocol & Handshake

All peer-to-peer communication occurs over the RLPx (Recursive Length Prefix Transport) protocol.

  • Encryption: RLPx uses a two-phase handshake.

    1. Cryptographic Handshake: An ECIES handshake is performed to exchange public keys and establish a shared secret.
    2. Session Encryption: This secret is used to derive AES-256 symmetric keys for the transport. All subsequent data is encrypted using AES-GCM (formerly AES-CTR + MAC).
  • Capability Negotiation: After the crypto handshake, peers exchange Hello messages. This message contains a list of supported subprotocols and their versions (e.g., eth/66, snap/1). Peers will only communicate using commonly supported protocols.

A typical connection flow is as follows:

1. Outbound TCP dial to a discovered peer.
2. RLPx handshake:
   - Send `auth` message with ephemeral public key.
   - Receive `ack` message from peer.
   - Derive shared secret and session keys.
3. Send `Hello` message with local capabilities (e.g., `[eth/66, snap/1]`).
4. Receive peer's `Hello` message.
5. Determine shared protocols and versions.
6. Enter the protocol message loop (`Peer.run()`).

7. Concurrency Model

The P2P system is heavily concurrent, relying on goroutines and channels for non-blocking operation.

  • Server.run(): The main server loop is a single goroutine that manages peer connections and dialing tasks. It avoids blocking by offloading I/O-heavy work (like dialing and handshakes) to other goroutines.
  • Peer.run(): Each Peer instance runs in its own goroutine. This method, in turn, spawns a readLoop and a sendLoop.
  • Message Queues: Outgoing messages are sent to the Peer via a channel (p2p.MsgPipeRW), which queues them for the sendLoop. This decouples protocol logic from network I/O. Incoming messages are read by readLoop and dispatched synchronously to the appropriate subprotocol handler.
  • Disconnects: If a read/write error occurs, or a timeout is reached, the Peer's connection is torn down. The p2p.Server detects the disconnect and may trigger the dialer to find a replacement peer.

8. System Diagram

┌────────────────────┐
Node.Start()│  → p2p.Server└───────┬────────────┘
┌────────────────────┐
Discovery (UDP)- Ping/Pong- FindNode/Neigh- Routing Table└───────┬────────────┘
┌────────────────────┐
TCP Connections- RLPx Handshake- Hello exchange  │
- Subprotocols└────────────────────┘

9. Key Architectural Concepts

  • Kademlia DHT Routing: For decentralized and efficient peer discovery.
  • RLPx Handshake & Encryption: Ensures all peer communication is secure and authenticated.
  • Protocol Multiplexing: Allows multiple application-level protocols to share a single TCP connection.
  • Peer Lifecycle Management: The server actively manages its peer set to maintain network connectivity.
  • Concurrency via Goroutines: The system is designed to be highly concurrent and non-blocking, enabling high throughput.

10. Extensibility & Integration

  • Pluggable Subprotocols: New protocols can be integrated by implementing the p2p.Protocol structure and registering it with the node.Node. The P2P layer handles the multiplexing automatically.
  • Discovery Mechanisms: The system is designed to support multiple discovery sources. While discv4 is the primary mechanism, static nodes can be added for fixed topologies, and the experimental discv5 is under development for a more efficient and flexible discovery protocol.
  • Future Work:
    • ENR (Ethereum Node Records): discv5 is based on ENRs, which are extensible records that allow nodes to advertise more metadata, such as the specific fork they are on.
    • Discv5 Migration: The long-term goal is to migrate fully to discv5 for its improved efficiency and security features.

11. Design Principles

  • Modularity: The P2P layer is a distinct component, decoupled from the core consensus logic. Discovery, transport, and protocol layers are also separated.
  • Secure & Decentralized: Discovery and communication are designed to operate without central coordinators, and all connections are encrypted and authenticated.
  • Deterministic Message Flow: Message handling is predictable, with clear queuing and dispatching logic.
  • Pluggable Subprotocols: The system does not have intrinsic knowledge of eth or other protocols; they are treated as plugins.
  • Graceful Failure & Recovery: The server is designed to recover from individual peer disconnects and continue operating.

12. Summary

The Go-Ethereum P2P stack is a sophisticated, multi-layered system that forms the communication backbone of the network. It begins with a decentralized Kademlia-based discovery protocol to find peers, establishes secure and authenticated RLPx connections, and multiplexes various subprotocols over these connections. The entire lifecycle, from discovery to protocol messaging, is managed concurrently to ensure a robust and high-performance network presence for the node.

Implementation

The following code examples provide a more detailed look into the implementation of the key components of the P2P and discovery subsystems.

P2P Server Lifecycle

The p2p.Server is the central component that manages the entire P2P stack. Its Start method initializes the discovery mechanism and starts the listeners.

File: p2p/server.go

// Start starts the server.
// It returns an error if the server is already running.
func (srv *Server) Start() error {
	srv.lock.Lock()
	defer srv.lock.Unlock()
	if srv.running {
		return errors.New("server already running")
	}
	srv.running = true
	srv.log.Info("Starting P2P networking")

	// discovery
	if srv.DiscoveryV4() != nil {
		srv.DiscoveryV4().Start()
	}
	if srv.DiscoveryV5() != nil {
		srv.DiscoveryV5().Start()
	}

	// listener
	if err := srv.startListening(); err != nil {
		return err
	}

	// dialer
	srv.quit = make(chan struct{})
	srv.wg.Add(1)
	go srv.run()
	return nil
}

The run method is the main loop of the server, responsible for managing the peer set by dialing new peers and handling existing ones.

// run is the main loop of the server.
func (srv *Server) run() {
	defer srv.wg.Done()
	srv.log.Trace("P2P server main loop starting")

	var (
		peers        = make(map[enode.ID]*Peer)
		inboundCount = 0
		trusted      = make(map[enode.ID]bool)
	)
	// The 'srv.peers' map is the canonical list of connected peers.
	// It is grown by 'srv.runTask' and shrunk by 'srv.removePeer'.
	// The 'peers' map below is a snapshot of srv.peers.
	// It is used to check for duplicate pending connections.
	// The server run loop is the only place that modifies srv.peers.
	// All other accesses are protected by srv.lock.

	running := true
	for running {
		// Wait for something to happen.
		// This is the main select statement of the server.
		select {
		case <-srv.quit:
			running = false
		// ... (handling of new connections, disconnections, etc.)
		}
	}

	srv.log.Trace("P2P server main loop stopping")
	srv.stopListening()

	// Disconnect all peers.
	for _, p := range srv.peers {
		p.Disconnect(DiscQuitting)
	}
	// Wait for peers to shut down.
	for len(srv.peers) > 0 {
		p := <-srv.delpeer
		p.wait()
		delete(srv.peers, p.ID())
	}
}

Peer Discovery (UDPv4)

The v4 discovery protocol is implemented in p2p/discover/v4_udp.go. The loop function is the main packet dispatcher.

File: p2p/discover/v4_udp.go

// loop is the main packet dispatcher.
func (t *UDPv4) loop() {
	defer t.wg.Done()
	for {
		select {
		case <-t.closing:
			return
		case p := <-t.read:
			t.handlePacket(p.addr, p.data)
		case now := <-t.clock.Chan():
			t.tick(now)
		}
	}
}

func (t *UDPv4) handlePacket(addr *net.UDPAddr, data []byte) {
	// ...
	req, fromKey, hash, err := v4wire.Decode(data)
	// ...
	switch req.Kind() {
	case v4wire.PingPacket:
		// ...
	case v4wire.PongPacket:
		// ...
	case v4wire.FindnodePacket:
		// ...
	case v4wire.NeighborsPacket:
		// ...
	}
}

RLPx Handshake

The RLPx handshake is implemented in p2p/rlpx/rlpx.go. The Handshake function performs the key exchange and sets up the encrypted session.

File: p2p/rlpx/rlpx.go

// Handshake performs the handshake. This must be called before any data is written
// or read from the connection.
func (c *Conn) Handshake(prv *ecdsa.PrivateKey) (*ecdsa.PublicKey, error) {
	var (
		sec Secrets
		err error
		h   handshakeState
	)
	if c.dialDest != nil {
		sec, err = h.runInitiator(c.conn, prv, c.dialDest)
	} else {
		sec, err = h.runRecipient(c.conn, prv)
	}
	if err != nil {
		return nil, err
	}
	c.InitWithSecrets(sec)
	c.session.rbuf = h.rbuf
	c.session.wbuf = h.wbuf
	return sec.remote, err
}

Peer Message Handling

Once a peer is connected and handshaked, the p2p.Peer.run method manages the message loops.

File: p2p/peer.go

// run is the main loop of a peer. It starts the protocol handlers and waits for
// messages to be sent or received.
func (p *Peer) run() (remoteRequested bool, err error) {
	// ...
	p.wg.Add(2)
	go p.readLoop(readErr)
	go p.pingLoop()
	// ...
	// Start all protocol handlers.
	writeStart <- struct{}{}
	p.startProtocols(writeStart, writeErr)

	// Wait for an error or disconnect.
loop:
	for {
		select {
		case err = <-writeErr:
			// ...
		case err = <-readErr:
			// ...
		case err = <-p.protoErr:
			// ...
		case err = <-p.disc:
			// ...
		}
	}
	// ...
}

func (p *Peer) readLoop(errc chan<- error) {
	defer p.wg.Done()
	for {
		msg, err := p.rw.ReadMsg()
		if err != nil {
			errc <- err
			return
		}
		msg.ReceivedAt = time.Now()
		if err = p.handle(msg); err != nil {
			errc <- err
			return
		}
	}
}

The readLoop reads messages from the underlying connection and handle dispatches them to the appropriate subprotocol.