From 7cfebcd5e348c7b7826808d4605dc0d9a7e2a06f Mon Sep 17 00:00:00 2001 From: Thomas Coratger Date: Tue, 6 Jan 2026 00:27:56 +0100 Subject: [PATCH 1/4] networking: add discovery support --- .../networking/support/discovery/__init__.py | 73 ++ .../networking/support/discovery/config.py | 65 ++ .../networking/support/discovery/messages.py | 375 +++++++++ .../networking/support/discovery/routing.py | 362 ++++++++ .../subspecs/networking/test_discovery.py | 792 ++++++++++++++++++ 5 files changed, 1667 insertions(+) create mode 100644 src/lean_spec/subspecs/networking/support/discovery/__init__.py create mode 100644 src/lean_spec/subspecs/networking/support/discovery/config.py create mode 100644 src/lean_spec/subspecs/networking/support/discovery/messages.py create mode 100644 src/lean_spec/subspecs/networking/support/discovery/routing.py create mode 100644 tests/lean_spec/subspecs/networking/test_discovery.py diff --git a/src/lean_spec/subspecs/networking/support/discovery/__init__.py b/src/lean_spec/subspecs/networking/support/discovery/__init__.py new file mode 100644 index 00000000..cd7dfe67 --- /dev/null +++ b/src/lean_spec/subspecs/networking/support/discovery/__init__.py @@ -0,0 +1,73 @@ +""" +Discovery v5 Protocol Specification + +Node Discovery Protocol v5.1 for finding peers in Ethereum networks. + +References: + - https://github.com/ethereum/devp2p/blob/master/discv5/discv5.md +""" + +from .config import DiscoveryConfig +from .messages import ( + # Protocol constants + MAX_REQUEST_ID_LENGTH, + PROTOCOL_ID, + PROTOCOL_VERSION, + # Custom types + Distance, + # Protocol messages + FindNode, + # Packet structures + HandshakeAuthdata, + IdNonce, + IPv4, + IPv6, + # Enums + MessageType, + Nodes, + Nonce, + PacketFlag, + Ping, + Pong, + Port, + RequestId, + StaticHeader, + TalkReq, + TalkResp, + WhoAreYouAuthdata, +) +from .routing import KBucket, NodeEntry, RoutingTable + +__all__ = [ + # Config + "DiscoveryConfig", + # Constants + "MAX_REQUEST_ID_LENGTH", + "PROTOCOL_ID", + "PROTOCOL_VERSION", + "Distance", + "IdNonce", + "IPv4", + "IPv6", + "Nonce", + "Port", + "RequestId", + # Enums + "MessageType", + "PacketFlag", + # Messages + "FindNode", + "Nodes", + "Ping", + "Pong", + "TalkReq", + "TalkResp", + # Packet structures + "HandshakeAuthdata", + "StaticHeader", + "WhoAreYouAuthdata", + # Routing + "KBucket", + "NodeEntry", + "RoutingTable", +] diff --git a/src/lean_spec/subspecs/networking/support/discovery/config.py b/src/lean_spec/subspecs/networking/support/discovery/config.py new file mode 100644 index 00000000..ec8e56e7 --- /dev/null +++ b/src/lean_spec/subspecs/networking/support/discovery/config.py @@ -0,0 +1,65 @@ +""" +Discovery v5 Configuration + +Protocol constants and configuration for Node Discovery Protocol v5.1. + +References: + - https://github.com/ethereum/devp2p/blob/master/discv5/discv5-theory.md +""" + +from typing_extensions import Final + +from lean_spec.types import StrictBaseModel + +# Protocol Constants +# ------------------ +# Values derived from the Discovery v5 specification and Kademlia design. + +K_BUCKET_SIZE: Final = 16 +"""Nodes per k-bucket. Standard Kademlia value balancing table size and lookup efficiency.""" + +ALPHA: Final = 3 +"""Concurrent queries during lookup. Balances speed against network load.""" + +BUCKET_COUNT: Final = 256 +"""Total k-buckets. One per bit of the 256-bit node ID space.""" + +REQUEST_TIMEOUT_SECS: Final = 0.5 +"""Single request timeout. Spec recommends 500ms for request/response.""" + +HANDSHAKE_TIMEOUT_SECS: Final = 1.0 +"""Handshake completion timeout. Spec recommends 1s for full handshake.""" + +MAX_NODES_RESPONSE: Final = 16 +"""Max ENRs per NODES message. Keeps responses under 1280 byte UDP limit.""" + +BOND_EXPIRY_SECS: Final = 86400 +"""Liveness revalidation interval. 24 hours before re-checking a node.""" + +MAX_PACKET_SIZE: Final = 1280 +"""Maximum UDP packet size in bytes.""" + +MIN_PACKET_SIZE: Final = 63 +"""Minimum valid packet size in bytes.""" + + +class DiscoveryConfig(StrictBaseModel): + """Runtime configuration for Discovery v5.""" + + k_bucket_size: int = K_BUCKET_SIZE + """Maximum nodes stored per k-bucket in the routing table.""" + + alpha: int = ALPHA + """Number of concurrent FINDNODE queries during lookup.""" + + request_timeout_secs: float = REQUEST_TIMEOUT_SECS + """Timeout for a single request/response exchange.""" + + handshake_timeout_secs: float = HANDSHAKE_TIMEOUT_SECS + """Timeout for completing the full handshake sequence.""" + + max_nodes_response: int = MAX_NODES_RESPONSE + """Maximum ENR records returned in a single NODES response.""" + + bond_expiry_secs: int = BOND_EXPIRY_SECS + """Seconds before a bonded node requires liveness revalidation.""" diff --git a/src/lean_spec/subspecs/networking/support/discovery/messages.py b/src/lean_spec/subspecs/networking/support/discovery/messages.py new file mode 100644 index 00000000..94ae613a --- /dev/null +++ b/src/lean_spec/subspecs/networking/support/discovery/messages.py @@ -0,0 +1,375 @@ +""" +Discovery v5 Protocol Messages + +Wire protocol messages for Node Discovery Protocol v5.1. + +Packet Structure: + packet = masking-iv || masked-header || message + +Message Encoding: + message-pt = message-type || message-data + message-data = [request-id, ...] (RLP encoded) + +References: + - https://github.com/ethereum/devp2p/blob/master/discv5/discv5-wire.md +""" + +from __future__ import annotations + +from enum import IntEnum +from typing import ClassVar + +from lean_spec.subspecs.networking.types import SeqNumber +from lean_spec.types import StrictBaseModel +from lean_spec.types.byte_arrays import BaseByteList, BaseBytes +from lean_spec.types.uint import Uint8, Uint16 + +# ============================================================================= +# Protocol Constants +# ============================================================================= + +PROTOCOL_ID: bytes = b"discv5" +"""Protocol identifier in packet header. 6 bytes.""" + +PROTOCOL_VERSION: int = 0x0001 +"""Current protocol version (v5.1).""" + +MAX_REQUEST_ID_LENGTH: int = 8 +"""Maximum length of request-id in bytes.""" + +# Note: Max ENR size (300 bytes) is defined in ENR.MAX_SIZE (enr/enr.py) + +# ============================================================================= +# Custom Types +# ============================================================================= + + +class RequestId(BaseByteList): + """ + Request identifier for matching requests with responses. + + Variable length up to 8 bytes. Assigned by the requester and echoed + in responses. Selection of values is implementation-defined. + """ + + LIMIT: ClassVar[int] = MAX_REQUEST_ID_LENGTH + + +class IPv4(BaseBytes): + """IPv4 address as 4 bytes.""" + + LENGTH: ClassVar[int] = 4 + + +class IPv6(BaseBytes): + """IPv6 address as 16 bytes.""" + + LENGTH: ClassVar[int] = 16 + + +class IdNonce(BaseBytes): + """ + Identity nonce for WHOAREYOU packets. + + 128-bit random value used in the identity verification procedure. + """ + + LENGTH: ClassVar[int] = 16 + + +class Nonce(BaseBytes): + """ + Message nonce for packet encryption. + + 96-bit value. Must be unique for every message packet. + """ + + LENGTH: ClassVar[int] = 12 + + +# ============================================================================= +# Type Aliases +# ============================================================================= + +Distance = Uint16 +"""Log2 distance (0-256). Distance 0 returns the node's own ENR.""" + +Port = Uint16 +"""UDP port number (0-65535).""" + + +# ============================================================================= +# Packet Type Flags +# ============================================================================= + + +class PacketFlag(IntEnum): + """ + Packet type identifier in the protocol header. + + Determines the encoding of the authdata section. + """ + + MESSAGE = 0 + """Ordinary message packet. authdata = src-id (32 bytes).""" + + WHOAREYOU = 1 + """Challenge packet. authdata = id-nonce || enr-seq (24 bytes).""" + + HANDSHAKE = 2 + """Handshake message packet. authdata = variable size.""" + + +# ============================================================================= +# Message Type Identifiers +# ============================================================================= + + +class MessageType(IntEnum): + """ + Message type identifiers in the encrypted message payload. + + Encoded as the first byte of message-pt before RLP message-data. + """ + + PING = 0x01 + """Liveness check. message-data = [request-id, enr-seq].""" + + PONG = 0x02 + """Response to PING. message-data = [request-id, enr-seq, ip, port].""" + + FINDNODE = 0x03 + """Query nodes. message-data = [request-id, [distances...]].""" + + NODES = 0x04 + """Response with ENRs. message-data = [request-id, total, [ENRs...]].""" + + TALKREQ = 0x05 + """App protocol request. message-data = [request-id, protocol, request].""" + + TALKRESP = 0x06 + """App protocol response. message-data = [request-id, response].""" + + # Topic advertisement messages (not finalized in spec) + REGTOPIC = 0x07 + """Topic registration request (experimental).""" + + TICKET = 0x08 + """Ticket response for topic registration (experimental).""" + + REGCONFIRMATION = 0x09 + """Topic registration confirmation (experimental).""" + + TOPICQUERY = 0x0A + """Topic query request (experimental).""" + + +# ============================================================================= +# Protocol Messages +# ============================================================================= + + +class Ping(StrictBaseModel): + """ + PING request (0x01) - Liveness check. + + Verifies a node is online and informs it of the sender's ENR sequence number. + The recipient compares enr_seq to decide if it needs the sender's latest record. + + Wire format: + message-data = [request-id, enr-seq] + """ + + request_id: RequestId + """Unique identifier for request/response matching.""" + + enr_seq: SeqNumber + """Sender's ENR sequence number.""" + + +class Pong(StrictBaseModel): + """ + PONG response (0x02) - Reply to PING. + + Confirms liveness and reports the sender's observed external endpoint. + Used for NAT detection and ENR endpoint verification. + + Wire format: + message-data = [request-id, enr-seq, recipient-ip, recipient-port] + """ + + request_id: RequestId + """Echoed from the PING request.""" + + enr_seq: SeqNumber + """Responder's ENR sequence number.""" + + recipient_ip: bytes + """Sender's IP as seen by responder. 4 bytes (IPv4) or 16 bytes (IPv6).""" + + recipient_port: Port + """Sender's UDP port as seen by responder.""" + + +class FindNode(StrictBaseModel): + """ + FINDNODE request (0x03) - Query nodes at distances. + + Requests nodes from the recipient's routing table at specified log2 distances. + The recommended result limit is 16 nodes per query. + + Wire format: + message-data = [request-id, [distance₁, distance₂, ...]] + + Distance semantics: + - Distance 0: Returns the recipient's own ENR + - Distance 1-256: Returns nodes at that log2 distance from recipient + """ + + request_id: RequestId + """Unique identifier for request/response matching.""" + + distances: list[Distance] + """Log2 distances to query. Each value in range 0-256.""" + + +class Nodes(StrictBaseModel): + """ + NODES response (0x04) - ENR records from routing table. + + Response to FINDNODE or TOPICQUERY. May be split across multiple messages + to stay within the 1280 byte UDP packet limit. + + Wire format: + message-data = [request-id, total, [ENR₁, ENR₂, ...]] + + Recipients should verify returned nodes match the requested distances. + """ + + request_id: RequestId + """Echoed from the FINDNODE request.""" + + total: Uint8 + """Total NODES messages for this request. Enables reassembly.""" + + enrs: list[bytes] + """RLP-encoded ENR records. Max 300 bytes each per EIP-778.""" + + +class TalkReq(StrictBaseModel): + """ + TALKREQ request (0x05) - Application protocol negotiation. + + Enables higher-layer protocols to communicate through Discovery v5. + Used by Ethereum for subnet discovery (eth2) and Portal Network. + + Wire format: + message-data = [request-id, protocol, request] + + The recipient must respond with TALKRESP. If the protocol is unknown, + the response must contain empty data. + """ + + request_id: RequestId + """Unique identifier for request/response matching.""" + + protocol: bytes + """Protocol identifier (e.g., b"eth2", b"portal").""" + + request: bytes + """Protocol-specific request payload.""" + + +class TalkResp(StrictBaseModel): + """ + TALKRESP response (0x06) - Reply to TALKREQ. + + Empty response indicates the protocol is unknown to the recipient. + + Wire format: + message-data = [request-id, response] + """ + + request_id: RequestId + """Echoed from the TALKREQ request.""" + + response: bytes + """Protocol-specific response. Empty if protocol unknown.""" + + +# ============================================================================= +# Packet Header Structures +# ============================================================================= + + +class StaticHeader(StrictBaseModel): + """ + Fixed-size portion of the packet header. + + Total size: 23 bytes (6 + 2 + 1 + 12 + 2). + + The header is masked using AES-CTR with masking-key = dest-id[:16]. + """ + + protocol_id: bytes = PROTOCOL_ID + """Protocol identifier. Must be b"discv5" (6 bytes).""" + + version: Uint16 = Uint16(PROTOCOL_VERSION) + """Protocol version. Currently 0x0001.""" + + flag: Uint8 + """Packet type: 0=message, 1=whoareyou, 2=handshake.""" + + nonce: Nonce + """96-bit message nonce. Must be unique per packet.""" + + authdata_size: Uint16 + """Byte length of the authdata section following this header.""" + + +class WhoAreYouAuthdata(StrictBaseModel): + """ + Authdata for WHOAREYOU packets (flag=1). + + Sent when the recipient cannot decrypt an incoming message packet. + The nonce in the packet header is set to the nonce of the failed message. + + Total size: 24 bytes (16 + 8). + """ + + id_nonce: IdNonce + """128-bit random value for identity verification.""" + + enr_seq: SeqNumber + """Recipient's known ENR sequence for the sender. 0 if unknown.""" + + +class HandshakeAuthdata(StrictBaseModel): + """ + Authdata for handshake packets (flag=2). + + Variable size depending on identity scheme. For "v4" scheme: + - sig_size = 64 bytes + - eph_key_size = 33 bytes (compressed secp256k1) + - record = optional, included if enr_seq in WHOAREYOU was stale + + Total size: 34 + sig_size + eph_key_size + len(record). + """ + + src_id: bytes + """Sender's 32-byte node ID.""" + + sig_size: Uint8 + """Size of the ID signature. 64 for "v4" scheme.""" + + eph_key_size: Uint8 + """Size of the ephemeral public key. 33 for "v4" scheme.""" + + id_signature: bytes + """Identity proof signature. Length specified by sig_size.""" + + eph_pubkey: bytes + """Ephemeral public key for ECDH. Length specified by eph_key_size.""" + + record: bytes | None = None + """Sender's ENR. Included only if recipient's enr_seq was stale.""" diff --git a/src/lean_spec/subspecs/networking/support/discovery/routing.py b/src/lean_spec/subspecs/networking/support/discovery/routing.py new file mode 100644 index 00000000..5695d779 --- /dev/null +++ b/src/lean_spec/subspecs/networking/support/discovery/routing.py @@ -0,0 +1,362 @@ +""" +Discovery v5 Routing Table + +Kademlia-style routing table for Node Discovery Protocol v5.1. + +Node Table Structure +-------------------- +Nodes keep information about other nodes in their neighborhood. Neighbor nodes +are stored in a routing table consisting of 'k-buckets'. For each 0 <= i < 256, +every node keeps a k-bucket for nodes of logdistance(self, n) == i. + +The protocol uses k = 16, meaning every k-bucket contains up to 16 node entries. +Entries are sorted by time last seen: least-recently seen at head, most-recently +seen at tail. + +Distance Metric +--------------- +The 'distance' between two node IDs is the bitwise XOR of the IDs, interpreted +as a big-endian number: + + distance(n1, n2) = n1 XOR n2 + +The logarithmic distance (length of differing suffix in bits) is used for +bucket assignment: + + logdistance(n1, n2) = log2(distance(n1, n2)) + +Bucket Eviction Policy +---------------------- +When a new node N1 is encountered, it can be inserted into the corresponding +bucket. + +- If the bucket contains less than k entries, N1 is simply added. + +- If the bucket already contains k entries, the liveness of the least recently seen +node N2 must be revalidated. If no reply is received from N2, it is considered dead, +removed, and N1 added to the front of the bucket. + +Liveness Verification +--------------------- +Implementations should perform liveness checks asynchronously and occasionally +verify that a random node in a random bucket is live by sending PING. When +responding to FINDNODE, implementations must avoid relaying any nodes whose +liveness has not been verified. + +References: + - https://github.com/ethereum/devp2p/blob/master/discv5/discv5-theory.md + - Maymounkov & Mazieres, "Kademlia: A Peer-to-peer Information System", 2002 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterator + +from lean_spec.subspecs.networking.types import NodeId, SeqNumber + +from .config import BUCKET_COUNT, K_BUCKET_SIZE +from .messages import Distance + + +def xor_distance(a: NodeId, b: NodeId) -> int: + """ + Compute XOR distance between two node IDs. + + XOR distance is the fundamental metric in Kademlia networks: + + distance(n1, n2) = n1 XOR n2 + + Properties: + - Symmetric: d(a, b) == d(b, a) + - Identity: d(a, a) == 0 + - Triangle inequality: d(a, c) <= d(a, b) XOR d(b, c) + + Args: + a: First 32-byte node ID. + b: Second 32-byte node ID. + + Returns: + XOR of the two IDs as a big-endian integer (0 to 2^256 - 1). + """ + return int.from_bytes(a, "big") ^ int.from_bytes(b, "big") + + +def log2_distance(a: NodeId, b: NodeId) -> Distance: + """ + Compute log2 of XOR distance between two node IDs. + + Determines which k-bucket a node belongs to: + + logdistance(n1, n2) = log2(distance(n1, n2)) + + Equivalent to the bit position of the highest differing bit. + Used for bucket assignment in the routing table. + + Args: + a: First 32-byte node ID. + b: Second 32-byte node ID. + + Returns: + Log2 distance (0-256). Returns 0 for identical IDs. + """ + distance = xor_distance(a, b) + if distance == 0: + return Distance(0) + return Distance(distance.bit_length()) + + +@dataclass +class NodeEntry: + """ + Entry in the routing table representing a discovered node. + + Tracks node identity and liveness information for routing decisions. + Nodes should only be relayed in FINDNODE responses if verified is True. + """ + + node_id: NodeId + """32-byte node identifier derived from keccak256(pubkey).""" + + enr_seq: SeqNumber = field(default_factory=lambda: SeqNumber(0)) + """Last known ENR sequence number. Used to detect stale records.""" + + last_seen: float = 0.0 + """Unix timestamp of last successful contact.""" + + endpoint: str | None = None + """Network endpoint in 'ip:port' format.""" + + verified: bool = False + """True if node has responded to at least one PING. Required for relay.""" + + +@dataclass +class KBucket: + """ + K-bucket holding nodes at a specific log2 distance range. + + Implements Kademlia's bucket semantics per the Discovery v5 spec: + - Fixed capacity of k = 16 nodes + - Least-recently seen at head, most-recently seen at tail + - New nodes added to tail, eviction candidates at head + + Eviction Policy + --------------- + When full, ping the head node (least-recently seen). + - If it responds, keep it and discard the new node. + - If it fails, evict it and add the new node. + + Replacement Cache + ----------------- + Implementations should maintain a 'replacement cache' alongside each bucket. + This cache holds recently-seen nodes which would fall into the corresponding + bucket but cannot become a member because it is at capacity. Once a bucket + member becomes unresponsive, a replacement can be chosen from the cache. + """ + + nodes: list[NodeEntry] = field(default_factory=list) + """Ordered list of node entries. Head = oldest, tail = newest.""" + + @property + def is_full(self) -> bool: + """True if bucket has reached k = 16 capacity.""" + return len(self.nodes) >= K_BUCKET_SIZE + + @property + def is_empty(self) -> bool: + """True if bucket contains no nodes.""" + return len(self.nodes) == 0 + + def __len__(self) -> int: + """Number of nodes in this bucket.""" + return len(self.nodes) + + def __iter__(self) -> Iterator[NodeEntry]: + """Iterate over nodes from oldest to newest.""" + return iter(self.nodes) + + def contains(self, node_id: NodeId) -> bool: + """Check if node ID exists in this bucket.""" + return any(entry.node_id == node_id for entry in self.nodes) + + def get(self, node_id: NodeId) -> NodeEntry | None: + """Retrieve node entry by ID. Returns None if not found.""" + for entry in self.nodes: + if entry.node_id == node_id: + return entry + return None + + def add(self, entry: NodeEntry) -> bool: + """ + Add or update a node in the bucket. + + - If the node exists, moves it to the tail (most recent). + - If the bucket is full, returns False without adding. + + Note: Caller should implement eviction by pinging the head node + when this returns False. + + Args: + entry: Node entry to add. + + Returns: + - True if node was added or updated, + - False if bucket is full. + """ + for i, existing in enumerate(self.nodes): + if existing.node_id == entry.node_id: + self.nodes.pop(i) + self.nodes.append(entry) + return True + + if self.is_full: + return False + + self.nodes.append(entry) + return True + + def remove(self, node_id: NodeId) -> bool: + """ + Remove a node from the bucket. + + Args: + node_id: 32-byte node ID to remove. + + Returns: + - True if node was removed, + - False if not found. + """ + for i, entry in enumerate(self.nodes): + if entry.node_id == node_id: + self.nodes.pop(i) + return True + return False + + def head(self) -> NodeEntry | None: + """Get least-recently seen node (eviction candidate).""" + return self.nodes[0] if self.nodes else None + + def tail(self) -> NodeEntry | None: + """Get most-recently seen node.""" + return self.nodes[-1] if self.nodes else None + + +@dataclass +class RoutingTable: + """ + Kademlia routing table for Discovery v5. + + Organizes nodes into 256 k-buckets by XOR distance from the local node. + Bucket i contains nodes with log2(distance) == i + 1. + + Lookup Algorithm + ---------------- + A 'lookup' locates the k closest nodes to a target ID. The initiator + starts by picking alpha (typically 3) closest nodes from the local table. + It then sends FINDNODE requests to those nodes. As NODES responses are + received, the initiator resends FINDNODE to nodes learned from previous + queries, progressively approaching the target. + + The lookup terminates when the initiator has queried and gotten responses + from the k closest nodes it has seen. + + Table Maintenance + ----------------- + Nodes are expected to keep track of their close neighbors and regularly + refresh their information. A lookup targeting the least recently refreshed + bucket should be performed at regular intervals. + """ + + local_id: NodeId + """This node's 32-byte identifier derived from keccak256(pubkey).""" + + buckets: list[KBucket] = field(default_factory=lambda: [KBucket() for _ in range(BUCKET_COUNT)]) + """256 k-buckets indexed by log2 distance minus one.""" + + def bucket_index(self, node_id: NodeId) -> int: + """ + Get bucket index for a node ID. + + Bucket i contains nodes with log2(distance) == i + 1. + + Args: + node_id: 32-byte node ID to look up. + + Returns: + Bucket index (0-255). + """ + distance = log2_distance(self.local_id, node_id) + return max(0, int(distance) - 1) + + def get_bucket(self, node_id: NodeId) -> KBucket: + """Get the k-bucket containing nodes at this distance.""" + return self.buckets[self.bucket_index(node_id)] + + def add(self, entry: NodeEntry) -> bool: + """ + Add a node to the routing table. + + Args: + entry: Node entry to add. + + Returns: + - True if added/updated, + - False if bucket full or adding self. + """ + if entry.node_id == self.local_id: + return False + return self.get_bucket(entry.node_id).add(entry) + + def remove(self, node_id: NodeId) -> bool: + """Remove a node from the routing table.""" + return self.get_bucket(node_id).remove(node_id) + + def get(self, node_id: NodeId) -> NodeEntry | None: + """Get a node entry by ID. Returns None if not found.""" + return self.get_bucket(node_id).get(node_id) + + def contains(self, node_id: NodeId) -> bool: + """Check if a node ID exists in the routing table.""" + return self.get(node_id) is not None + + def node_count(self) -> int: + """Total number of nodes across all buckets.""" + return sum(len(bucket) for bucket in self.buckets) + + def closest_nodes(self, target: NodeId, count: int) -> list[NodeEntry]: + """ + Find the closest nodes to a target ID. + + Used during Kademlia lookup to iteratively approach the target. + The lookup initiator picks alpha closest nodes and sends FINDNODE + requests, progressively querying closer nodes. + + Args: + target: Target 32-byte node ID. + count: Maximum nodes to return (typically k = 16). + + Returns: + Nodes sorted by XOR distance to target, closest first. + """ + all_nodes = [entry for bucket in self.buckets for entry in bucket] + all_nodes.sort(key=lambda e: xor_distance(e.node_id, target)) + return all_nodes[:count] + + def nodes_at_distance(self, distance: Distance) -> list[NodeEntry]: + """ + Get all nodes at a specific log2 distance. + + Used to respond to FINDNODE requests. The recipient returns nodes + from its routing table at the requested distance. + + Args: + distance: Log2 distance (1-256). Distance 0 returns own ENR. + + Returns: + List of nodes at the specified distance. + """ + dist_int = int(distance) + if dist_int < 1 or dist_int > BUCKET_COUNT: + return [] + return list(self.buckets[dist_int - 1]) diff --git a/tests/lean_spec/subspecs/networking/test_discovery.py b/tests/lean_spec/subspecs/networking/test_discovery.py new file mode 100644 index 00000000..32f03f6a --- /dev/null +++ b/tests/lean_spec/subspecs/networking/test_discovery.py @@ -0,0 +1,792 @@ +""" +Tests for Discovery v5 Protocol Specification + +Test vectors from: https://github.com/ethereum/devp2p/blob/master/discv5/discv5-wire-test-vectors.md +""" + +from lean_spec.subspecs.networking.support.discovery import ( + MAX_REQUEST_ID_LENGTH, + PROTOCOL_ID, + PROTOCOL_VERSION, + DiscoveryConfig, + Distance, + FindNode, + IdNonce, + IPv4, + IPv6, + KBucket, + MessageType, + Nodes, + Nonce, + PacketFlag, + Ping, + Pong, + Port, + RequestId, + RoutingTable, + StaticHeader, + TalkReq, + TalkResp, + WhoAreYouAuthdata, +) +from lean_spec.subspecs.networking.support.discovery.config import ( + ALPHA, + BOND_EXPIRY_SECS, + BUCKET_COUNT, + HANDSHAKE_TIMEOUT_SECS, + K_BUCKET_SIZE, + MAX_NODES_RESPONSE, + MAX_PACKET_SIZE, + MIN_PACKET_SIZE, + REQUEST_TIMEOUT_SECS, +) +from lean_spec.subspecs.networking.support.discovery.routing import ( + NodeEntry, + log2_distance, + xor_distance, +) +from lean_spec.subspecs.networking.types import NodeId, SeqNumber +from lean_spec.types.uint import Uint8, Uint16, Uint64 + + +class TestProtocolConstants: + """Verify protocol constants match Discovery v5 specification.""" + + def test_protocol_id(self) -> None: + """Protocol ID is 'discv5'.""" + assert PROTOCOL_ID == b"discv5" + assert len(PROTOCOL_ID) == 6 + + def test_protocol_version(self) -> None: + """Protocol version is 0x0001 (v5.1).""" + assert PROTOCOL_VERSION == 0x0001 + + def test_max_request_id_length(self) -> None: + """Request ID max length is 8 bytes.""" + assert MAX_REQUEST_ID_LENGTH == 8 + + def test_k_bucket_size(self) -> None: + """K-bucket size is 16 per Kademlia standard.""" + assert K_BUCKET_SIZE == 16 + + def test_alpha_concurrency(self) -> None: + """Alpha (lookup concurrency) is 3.""" + assert ALPHA == 3 + + def test_bucket_count(self) -> None: + """256 buckets for 256-bit node ID space.""" + assert BUCKET_COUNT == 256 + + def test_request_timeout(self) -> None: + """Request timeout is 500ms per spec.""" + assert REQUEST_TIMEOUT_SECS == 0.5 + + def test_handshake_timeout(self) -> None: + """Handshake timeout is 1s per spec.""" + assert HANDSHAKE_TIMEOUT_SECS == 1.0 + + def test_max_nodes_response(self) -> None: + """Max 16 ENRs per NODES response.""" + assert MAX_NODES_RESPONSE == 16 + + def test_bond_expiry(self) -> None: + """Bond expires after 24 hours.""" + assert BOND_EXPIRY_SECS == 86400 + + def test_packet_size_limits(self) -> None: + """Packet size limits per spec.""" + assert MAX_PACKET_SIZE == 1280 + assert MIN_PACKET_SIZE == 63 + + +class TestCustomTypes: + """Tests for custom Discovery v5 types.""" + + def test_request_id_limit(self) -> None: + """RequestId accepts up to 8 bytes.""" + req_id = RequestId(data=b"\x01\x02\x03\x04\x05\x06\x07\x08") + assert len(req_id.data) == 8 + + def test_request_id_variable_length(self) -> None: + """RequestId is variable length.""" + req_id = RequestId(data=b"\x01") + assert len(req_id.data) == 1 + + def test_ipv4_length(self) -> None: + """IPv4 is exactly 4 bytes.""" + ip = IPv4(b"\xc0\xa8\x01\x01") # 192.168.1.1 + assert len(ip) == 4 + + def test_ipv6_length(self) -> None: + """IPv6 is exactly 16 bytes.""" + ip = IPv6(b"\x00" * 15 + b"\x01") # ::1 + assert len(ip) == 16 + + def test_id_nonce_length(self) -> None: + """IdNonce is 16 bytes (128 bits).""" + nonce = IdNonce(b"\x01" * 16) + assert len(nonce) == 16 + + def test_nonce_length(self) -> None: + """Nonce is 12 bytes (96 bits).""" + nonce = Nonce(b"\x01" * 12) + assert len(nonce) == 12 + + def test_distance_type(self) -> None: + """Distance is Uint16.""" + d = Distance(256) + assert isinstance(d, Uint16) + + def test_port_type(self) -> None: + """Port is Uint16.""" + p = Port(30303) + assert isinstance(p, Uint16) + + def test_enr_seq_type(self) -> None: + """SeqNumber is Uint64.""" + seq = SeqNumber(42) + assert isinstance(seq, Uint64) + + +class TestPacketFlag: + """Tests for packet type flags.""" + + def test_message_flag(self) -> None: + """MESSAGE flag is 0.""" + assert PacketFlag.MESSAGE == 0 + + def test_whoareyou_flag(self) -> None: + """WHOAREYOU flag is 1.""" + assert PacketFlag.WHOAREYOU == 1 + + def test_handshake_flag(self) -> None: + """HANDSHAKE flag is 2.""" + assert PacketFlag.HANDSHAKE == 2 + + +class TestMessageTypes: + """Verify message type codes match wire protocol spec.""" + + def test_ping_type(self) -> None: + """PING is message type 0x01.""" + assert MessageType.PING == 0x01 + + def test_pong_type(self) -> None: + """PONG is message type 0x02.""" + assert MessageType.PONG == 0x02 + + def test_findnode_type(self) -> None: + """FINDNODE is message type 0x03.""" + assert MessageType.FINDNODE == 0x03 + + def test_nodes_type(self) -> None: + """NODES is message type 0x04.""" + assert MessageType.NODES == 0x04 + + def test_talkreq_type(self) -> None: + """TALKREQ is message type 0x05.""" + assert MessageType.TALKREQ == 0x05 + + def test_talkresp_type(self) -> None: + """TALKRESP is message type 0x06.""" + assert MessageType.TALKRESP == 0x06 + + def test_experimental_types(self) -> None: + """Experimental topic messages have correct types.""" + assert MessageType.REGTOPIC == 0x07 + assert MessageType.TICKET == 0x08 + assert MessageType.REGCONFIRMATION == 0x09 + assert MessageType.TOPICQUERY == 0x0A + + +class TestDiscoveryConfig: + """Tests for DiscoveryConfig.""" + + def test_default_values(self) -> None: + """Default config uses spec-defined constants.""" + config = DiscoveryConfig() + + assert config.k_bucket_size == K_BUCKET_SIZE + assert config.alpha == ALPHA + assert config.request_timeout_secs == REQUEST_TIMEOUT_SECS + assert config.handshake_timeout_secs == HANDSHAKE_TIMEOUT_SECS + assert config.max_nodes_response == MAX_NODES_RESPONSE + assert config.bond_expiry_secs == BOND_EXPIRY_SECS + + def test_custom_values(self) -> None: + """Custom config values override defaults.""" + config = DiscoveryConfig( + k_bucket_size=8, + alpha=5, + request_timeout_secs=2.0, + ) + assert config.k_bucket_size == 8 + assert config.alpha == 5 + assert config.request_timeout_secs == 2.0 + + +class TestPing: + """Tests for PING message.""" + + def test_creation_with_types(self) -> None: + """PING with strongly typed fields.""" + ping = Ping( + request_id=RequestId(data=b"\x00\x00\x00\x01"), + enr_seq=SeqNumber(2), + ) + + assert ping.request_id.data == b"\x00\x00\x00\x01" + assert ping.enr_seq == SeqNumber(2) + + def test_max_request_id_length(self) -> None: + """Request ID accepts up to 8 bytes.""" + ping = Ping( + request_id=RequestId(data=b"\x01\x02\x03\x04\x05\x06\x07\x08"), + enr_seq=SeqNumber(1), + ) + assert len(ping.request_id.data) == 8 + + +class TestPong: + """Tests for PONG message.""" + + def test_creation_ipv4(self) -> None: + """PONG with IPv4 address (4 bytes).""" + pong = Pong( + request_id=RequestId(data=b"\x00\x00\x00\x01"), + enr_seq=SeqNumber(42), + recipient_ip=b"\xc0\xa8\x01\x01", # 192.168.1.1 + recipient_port=Port(9000), + ) + + assert pong.enr_seq == SeqNumber(42) + assert len(pong.recipient_ip) == 4 + assert pong.recipient_port == Port(9000) + + def test_creation_ipv6(self) -> None: + """PONG with IPv6 address (16 bytes).""" + ipv6 = b"\x00" * 15 + b"\x01" # ::1 + pong = Pong( + request_id=RequestId(data=b"\x01"), + enr_seq=SeqNumber(1), + recipient_ip=ipv6, + recipient_port=Port(30303), + ) + + assert len(pong.recipient_ip) == 16 + + +class TestFindNode: + """Tests for FINDNODE message.""" + + def test_single_distance(self) -> None: + """FINDNODE querying single distance.""" + findnode = FindNode( + request_id=RequestId(data=b"\x01"), + distances=[Distance(256)], + ) + + assert findnode.distances == [Distance(256)] + + def test_multiple_distances(self) -> None: + """FINDNODE querying multiple distances.""" + findnode = FindNode( + request_id=RequestId(data=b"\x01"), + distances=[Distance(0), Distance(1), Distance(255), Distance(256)], + ) + + assert Distance(0) in findnode.distances # Distance 0 returns node itself + assert Distance(256) in findnode.distances # Maximum distance + + def test_distance_zero_returns_self(self) -> None: + """Distance 0 is valid and returns recipient's ENR.""" + findnode = FindNode( + request_id=RequestId(data=b"\x01"), + distances=[Distance(0)], + ) + assert findnode.distances == [Distance(0)] + + +class TestNodes: + """Tests for NODES message.""" + + def test_single_response(self) -> None: + """NODES with single response (total=1).""" + nodes = Nodes( + request_id=RequestId(data=b"\x01"), + total=Uint8(1), + enrs=[b"enr:-example"], + ) + + assert nodes.total == Uint8(1) + assert len(nodes.enrs) == 1 + + def test_multiple_responses(self) -> None: + """NODES indicating multiple response messages.""" + nodes = Nodes( + request_id=RequestId(data=b"\x01"), + total=Uint8(3), + enrs=[b"enr1", b"enr2"], + ) + + assert nodes.total == Uint8(3) + assert len(nodes.enrs) == 2 + + +class TestTalkReq: + """Tests for TALKREQ message.""" + + def test_creation(self) -> None: + """TALKREQ with protocol identifier.""" + req = TalkReq( + request_id=RequestId(data=b"\x01"), + protocol=b"portal", + request=b"payload", + ) + + assert req.protocol == b"portal" + assert req.request == b"payload" + + +class TestTalkResp: + """Tests for TALKRESP message.""" + + def test_creation(self) -> None: + """TALKRESP with response data.""" + resp = TalkResp( + request_id=RequestId(data=b"\x01"), + response=b"response_data", + ) + + assert resp.response == b"response_data" + + def test_empty_response_unknown_protocol(self) -> None: + """Empty response indicates unknown protocol.""" + resp = TalkResp( + request_id=RequestId(data=b"\x01"), + response=b"", + ) + assert resp.response == b"" + + +class TestStaticHeader: + """Tests for packet static header.""" + + def test_default_protocol_id(self) -> None: + """Static header has correct default protocol ID.""" + header = StaticHeader( + flag=Uint8(0), + nonce=Nonce(b"\x00" * 12), + authdata_size=Uint16(32), + ) + + assert header.protocol_id == b"discv5" + assert header.version == Uint16(0x0001) + + def test_flag_values(self) -> None: + """Static header accepts different flag values.""" + for flag in [0, 1, 2]: + header = StaticHeader( + flag=Uint8(flag), + nonce=Nonce(b"\xff" * 12), + authdata_size=Uint16(32), + ) + assert header.flag == Uint8(flag) + + +class TestWhoAreYouAuthdata: + """Tests for WHOAREYOU authdata.""" + + def test_creation(self) -> None: + """WHOAREYOU authdata with id_nonce and enr_seq.""" + authdata = WhoAreYouAuthdata( + id_nonce=IdNonce(b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10"), + enr_seq=SeqNumber(0), + ) + + assert len(authdata.id_nonce) == 16 + assert authdata.enr_seq == SeqNumber(0) + + +class TestXorDistance: + """Tests for XOR distance calculation.""" + + def test_identical_ids_zero_distance(self) -> None: + """Identical node IDs have distance 0.""" + node_id = NodeId(b"\x00" * 32) + assert xor_distance(node_id, node_id) == 0 + + def test_complementary_ids_max_distance(self) -> None: + """All-zeros XOR all-ones gives maximum distance.""" + a = NodeId(b"\x00" * 32) + b = NodeId(b"\xff" * 32) + assert xor_distance(a, b) == 2**256 - 1 + + def test_distance_is_symmetric(self) -> None: + """XOR distance satisfies d(a,b) == d(b,a).""" + a = NodeId(b"\x12" * 32) + b = NodeId(b"\x34" * 32) + assert xor_distance(a, b) == xor_distance(b, a) + + def test_specific_xor_values(self) -> None: + """Verify specific XOR calculations.""" + a = NodeId(b"\x00" * 31 + b"\x05") # 5 + b = NodeId(b"\x00" * 31 + b"\x03") # 3 + assert xor_distance(a, b) == 6 # 5 XOR 3 = 6 + + +class TestLog2Distance: + """Tests for log2 distance calculation.""" + + def test_identical_ids_return_zero(self) -> None: + """Identical IDs return log2 distance 0.""" + node_id = NodeId(b"\x00" * 32) + assert log2_distance(node_id, node_id) == Distance(0) + + def test_single_bit_difference(self) -> None: + """Single bit difference in LSB gives distance 1.""" + a = NodeId(b"\x00" * 32) + b = NodeId(b"\x00" * 31 + b"\x01") + assert log2_distance(a, b) == Distance(1) + + def test_high_bit_difference(self) -> None: + """Difference in high bit gives distance 8.""" + a = NodeId(b"\x00" * 32) + b = NodeId(b"\x00" * 31 + b"\x80") # 0b10000000 + assert log2_distance(a, b) == Distance(8) + + def test_maximum_distance(self) -> None: + """Maximum distance is 256 bits.""" + a = NodeId(b"\x00" * 32) + b = NodeId(b"\x80" + b"\x00" * 31) # High bit of first byte set + assert log2_distance(a, b) == Distance(256) + + +class TestKBucket: + """Tests for K-bucket implementation.""" + + def test_new_bucket_is_empty(self) -> None: + """Newly created bucket has no nodes.""" + bucket = KBucket() + + assert bucket.is_empty + assert not bucket.is_full + assert len(bucket) == 0 + + def test_add_single_node(self) -> None: + """Adding a node increases bucket size.""" + bucket = KBucket() + entry = NodeEntry(node_id=NodeId(b"\x01" * 32)) + + assert bucket.add(entry) + assert len(bucket) == 1 + assert bucket.contains(NodeId(b"\x01" * 32)) + + def test_bucket_capacity_limit(self) -> None: + """Bucket rejects nodes when at K_BUCKET_SIZE capacity.""" + bucket = KBucket() + + for i in range(K_BUCKET_SIZE): + entry = NodeEntry(node_id=NodeId(bytes([i]) + b"\x00" * 31)) + assert bucket.add(entry) + + assert bucket.is_full + assert len(bucket) == K_BUCKET_SIZE + + extra = NodeEntry(node_id=NodeId(b"\xff" * 32)) + assert not bucket.add(extra) + assert len(bucket) == K_BUCKET_SIZE + + def test_update_moves_to_tail(self) -> None: + """Re-adding existing node moves it to tail (most recent).""" + bucket = KBucket() + + entry1 = NodeEntry(node_id=NodeId(b"\x01" * 32), enr_seq=SeqNumber(1)) + entry2 = NodeEntry(node_id=NodeId(b"\x02" * 32), enr_seq=SeqNumber(1)) + bucket.add(entry1) + bucket.add(entry2) + + updated = NodeEntry(node_id=NodeId(b"\x01" * 32), enr_seq=SeqNumber(2)) + bucket.add(updated) + + tail = bucket.tail() + assert tail is not None + assert tail.node_id == NodeId(b"\x01" * 32) + assert tail.enr_seq == SeqNumber(2) + + def test_remove_node(self) -> None: + """Removing node decreases bucket size.""" + bucket = KBucket() + entry = NodeEntry(node_id=NodeId(b"\x01" * 32)) + bucket.add(entry) + + assert bucket.remove(NodeId(b"\x01" * 32)) + assert bucket.is_empty + assert not bucket.contains(NodeId(b"\x01" * 32)) + + def test_remove_nonexistent_returns_false(self) -> None: + """Removing nonexistent node returns False.""" + bucket = KBucket() + assert not bucket.remove(NodeId(b"\x01" * 32)) + + def test_get_existing_node(self) -> None: + """Get retrieves node by ID.""" + bucket = KBucket() + entry = NodeEntry(node_id=NodeId(b"\x01" * 32), enr_seq=SeqNumber(42)) + bucket.add(entry) + + retrieved = bucket.get(NodeId(b"\x01" * 32)) + assert retrieved is not None + assert retrieved.enr_seq == SeqNumber(42) + + def test_get_nonexistent_returns_none(self) -> None: + """Get returns None for unknown node.""" + bucket = KBucket() + assert bucket.get(NodeId(b"\x01" * 32)) is None + + def test_head_returns_oldest(self) -> None: + """Head returns least-recently seen node.""" + bucket = KBucket() + bucket.add(NodeEntry(node_id=NodeId(b"\x01" * 32))) + bucket.add(NodeEntry(node_id=NodeId(b"\x02" * 32))) + + head = bucket.head() + assert head is not None + assert head.node_id == NodeId(b"\x01" * 32) + + def test_tail_returns_newest(self) -> None: + """Tail returns most-recently seen node.""" + bucket = KBucket() + bucket.add(NodeEntry(node_id=NodeId(b"\x01" * 32))) + bucket.add(NodeEntry(node_id=NodeId(b"\x02" * 32))) + + tail = bucket.tail() + assert tail is not None + assert tail.node_id == NodeId(b"\x02" * 32) + + def test_iteration(self) -> None: + """Bucket supports iteration over nodes.""" + bucket = KBucket() + bucket.add(NodeEntry(node_id=NodeId(b"\x01" * 32))) + bucket.add(NodeEntry(node_id=NodeId(b"\x02" * 32))) + + node_ids = [entry.node_id for entry in bucket] + assert len(node_ids) == 2 + + +class TestRoutingTable: + """Tests for Kademlia routing table.""" + + def test_new_table_is_empty(self) -> None: + """New routing table has no nodes.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) + + assert table.node_count() == 0 + + def test_has_256_buckets(self) -> None: + """Routing table has 256 k-buckets.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) + + assert len(table.buckets) == BUCKET_COUNT + + def test_add_node(self) -> None: + """Adding node increases count.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) + + entry = NodeEntry(node_id=NodeId(b"\x00" * 31 + b"\x01")) + assert table.add(entry) + assert table.node_count() == 1 + assert table.contains(entry.node_id) + + def test_cannot_add_self(self) -> None: + """Adding local node ID is rejected.""" + local_id = NodeId(b"\xab" * 32) + table = RoutingTable(local_id=local_id) + + entry = NodeEntry(node_id=local_id) + assert not table.add(entry) + assert table.node_count() == 0 + + def test_bucket_assignment_by_distance(self) -> None: + """Nodes placed in correct bucket by log2 distance.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) + + node_id = NodeId(b"\x00" * 31 + b"\x01") # log2 distance = 1 + entry = NodeEntry(node_id=node_id) + table.add(entry) + + bucket_idx = table.bucket_index(node_id) + assert bucket_idx == 0 # distance 1 -> bucket 0 + assert table.buckets[0].contains(node_id) + + def test_get_existing_node(self) -> None: + """Get retrieves node from table.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) + + entry = NodeEntry(node_id=NodeId(b"\x01" * 32), enr_seq=SeqNumber(99)) + table.add(entry) + + retrieved = table.get(entry.node_id) + assert retrieved is not None + assert retrieved.enr_seq == SeqNumber(99) + + def test_remove_node(self) -> None: + """Remove deletes node from table.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) + + entry = NodeEntry(node_id=NodeId(b"\x01" * 32)) + table.add(entry) + assert table.remove(entry.node_id) + assert not table.contains(entry.node_id) + + def test_closest_nodes_sorted_by_distance(self) -> None: + """closest_nodes returns nodes sorted by XOR distance.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) + + for i in range(1, 5): + entry = NodeEntry(node_id=NodeId(bytes([i]) + b"\x00" * 31)) + table.add(entry) + + target = NodeId(b"\x01" + b"\x00" * 31) + closest = table.closest_nodes(target, count=3) + + assert len(closest) == 3 + assert closest[0].node_id == target # Distance 0 to itself + + def test_closest_nodes_respects_count(self) -> None: + """closest_nodes returns at most count nodes.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) + + for i in range(10): + entry = NodeEntry(node_id=NodeId(bytes([i + 1]) + b"\x00" * 31)) + table.add(entry) + + closest = table.closest_nodes(NodeId(b"\x05" + b"\x00" * 31), count=3) + assert len(closest) == 3 + + def test_nodes_at_distance(self) -> None: + """nodes_at_distance returns nodes in specific bucket.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) + + node_id = NodeId(b"\x00" * 31 + b"\x01") # distance 1 + entry = NodeEntry(node_id=node_id) + table.add(entry) + + nodes = table.nodes_at_distance(Distance(1)) + assert len(nodes) == 1 + assert nodes[0].node_id == node_id + + def test_nodes_at_invalid_distance(self) -> None: + """Invalid distances return empty list.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) + + assert table.nodes_at_distance(Distance(0)) == [] + assert table.nodes_at_distance(Distance(257)) == [] + + +class TestNodeEntry: + """Tests for NodeEntry dataclass.""" + + def test_default_values(self) -> None: + """NodeEntry has sensible defaults.""" + entry = NodeEntry(node_id=NodeId(b"\x01" * 32)) + + assert entry.node_id == NodeId(b"\x01" * 32) + assert entry.enr_seq == SeqNumber(0) + assert entry.last_seen == 0.0 + assert entry.endpoint is None + assert entry.verified is False + + def test_full_construction(self) -> None: + """NodeEntry accepts all fields.""" + entry = NodeEntry( + node_id=NodeId(b"\x01" * 32), + enr_seq=SeqNumber(42), + last_seen=1234567890.0, + endpoint="192.168.1.1:30303", + verified=True, + ) + + assert entry.enr_seq == SeqNumber(42) + assert entry.endpoint == "192.168.1.1:30303" + assert entry.verified is True + + +class TestMessageConstructionFromTestVectors: + """Test message construction using official Discovery v5 test vector inputs.""" + + # From https://github.com/ethereum/devp2p/blob/master/discv5/discv5-wire-test-vectors.md + PING_REQUEST_ID = bytes.fromhex("00000001") + PING_ENR_SEQ = 2 + WHOAREYOU_ID_NONCE = bytes.fromhex("0102030405060708090a0b0c0d0e0f10") + + def test_ping_message_construction(self) -> None: + """Construct PING message matching test vector inputs.""" + ping = Ping( + request_id=RequestId(data=self.PING_REQUEST_ID), + enr_seq=SeqNumber(self.PING_ENR_SEQ), + ) + + assert ping.request_id.data == self.PING_REQUEST_ID + assert ping.enr_seq == SeqNumber(2) + + def test_whoareyou_authdata_construction(self) -> None: + """Construct WHOAREYOU authdata matching test vector inputs.""" + authdata = WhoAreYouAuthdata( + id_nonce=IdNonce(self.WHOAREYOU_ID_NONCE), + enr_seq=SeqNumber(0), + ) + + assert authdata.id_nonce == IdNonce(self.WHOAREYOU_ID_NONCE) + assert authdata.enr_seq == SeqNumber(0) + + def test_plaintext_message_type(self) -> None: + """PING message plaintext starts with message type 0x01.""" + # From AES-GCM test vector plaintext + plaintext = bytes.fromhex("01c20101") + assert plaintext[0] == MessageType.PING + + +class TestPacketStructure: + """Tests for Discovery v5 packet structure constants.""" + + def test_static_header_size(self) -> None: + """Static header is 23 bytes per spec.""" + # protocol-id (6) + version (2) + flag (1) + nonce (12) + authdata-size (2) + expected_size = 6 + 2 + 1 + 12 + 2 + assert expected_size == 23 + + +class TestRoutingWithTestVectorNodeIds: + """Tests using official test vector node IDs with routing functions.""" + + # Node IDs from official test vectors (keccak256 of uncompressed pubkey) + NODE_A_ID = bytes.fromhex("aaaa8419e9f49d0083561b48287df592939a8d19947d8c0ef88f2a4856a69fbb") + NODE_B_ID = bytes.fromhex("bbbb9d047f0488c0b5a93c1c3f2d8bafc7c8ff337024a55434a0d0555de64db9") + + def test_xor_distance_is_symmetric(self) -> None: + """XOR distance between test vector nodes is symmetric and non-zero.""" + node_a = NodeId(self.NODE_A_ID) + node_b = NodeId(self.NODE_B_ID) + + distance = xor_distance(node_a, node_b) + assert distance > 0 + assert xor_distance(node_a, node_b) == xor_distance(node_b, node_a) + + def test_log2_distance_is_high(self) -> None: + """Log2 distance between test vector nodes is high (differ in high bits).""" + node_a = NodeId(self.NODE_A_ID) + node_b = NodeId(self.NODE_B_ID) + + log_dist = log2_distance(node_a, node_b) + assert log_dist > Distance(200) From e2bdf1ace8587c54116d2129fef57e795154a0bd Mon Sep 17 00:00:00 2001 From: Thomas Coratger Date: Tue, 6 Jan 2026 00:29:32 +0100 Subject: [PATCH 2/4] cleanup --- .../subspecs/networking/support/discovery/__init__.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/lean_spec/subspecs/networking/support/discovery/__init__.py b/src/lean_spec/subspecs/networking/support/discovery/__init__.py index cd7dfe67..53351145 100644 --- a/src/lean_spec/subspecs/networking/support/discovery/__init__.py +++ b/src/lean_spec/subspecs/networking/support/discovery/__init__.py @@ -9,20 +9,15 @@ from .config import DiscoveryConfig from .messages import ( - # Protocol constants MAX_REQUEST_ID_LENGTH, PROTOCOL_ID, PROTOCOL_VERSION, - # Custom types Distance, - # Protocol messages FindNode, - # Packet structures HandshakeAuthdata, IdNonce, IPv4, IPv6, - # Enums MessageType, Nodes, Nonce, @@ -39,9 +34,7 @@ from .routing import KBucket, NodeEntry, RoutingTable __all__ = [ - # Config "DiscoveryConfig", - # Constants "MAX_REQUEST_ID_LENGTH", "PROTOCOL_ID", "PROTOCOL_VERSION", @@ -52,21 +45,17 @@ "Nonce", "Port", "RequestId", - # Enums "MessageType", "PacketFlag", - # Messages "FindNode", "Nodes", "Ping", "Pong", "TalkReq", "TalkResp", - # Packet structures "HandshakeAuthdata", "StaticHeader", "WhoAreYouAuthdata", - # Routing "KBucket", "NodeEntry", "RoutingTable", From 97f44f089d39bcca013570675fc35e61562f72cb Mon Sep 17 00:00:00 2001 From: Thomas Coratger Date: Tue, 6 Jan 2026 00:30:32 +0100 Subject: [PATCH 3/4] cleanup --- .../networking/support/discovery/messages.py | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/lean_spec/subspecs/networking/support/discovery/messages.py b/src/lean_spec/subspecs/networking/support/discovery/messages.py index 94ae613a..ffaee5c0 100644 --- a/src/lean_spec/subspecs/networking/support/discovery/messages.py +++ b/src/lean_spec/subspecs/networking/support/discovery/messages.py @@ -24,10 +24,6 @@ from lean_spec.types.byte_arrays import BaseByteList, BaseBytes from lean_spec.types.uint import Uint8, Uint16 -# ============================================================================= -# Protocol Constants -# ============================================================================= - PROTOCOL_ID: bytes = b"discv5" """Protocol identifier in packet header. 6 bytes.""" @@ -37,12 +33,6 @@ MAX_REQUEST_ID_LENGTH: int = 8 """Maximum length of request-id in bytes.""" -# Note: Max ENR size (300 bytes) is defined in ENR.MAX_SIZE (enr/enr.py) - -# ============================================================================= -# Custom Types -# ============================================================================= - class RequestId(BaseByteList): """ @@ -87,10 +77,6 @@ class Nonce(BaseBytes): LENGTH: ClassVar[int] = 12 -# ============================================================================= -# Type Aliases -# ============================================================================= - Distance = Uint16 """Log2 distance (0-256). Distance 0 returns the node's own ENR.""" @@ -98,11 +84,6 @@ class Nonce(BaseBytes): """UDP port number (0-65535).""" -# ============================================================================= -# Packet Type Flags -# ============================================================================= - - class PacketFlag(IntEnum): """ Packet type identifier in the protocol header. @@ -297,11 +278,6 @@ class TalkResp(StrictBaseModel): """Protocol-specific response. Empty if protocol unknown.""" -# ============================================================================= -# Packet Header Structures -# ============================================================================= - - class StaticHeader(StrictBaseModel): """ Fixed-size portion of the packet header. From 44fbdad0595b7b0a2876ff1e35dca0740d49bc8d Mon Sep 17 00:00:00 2001 From: Thomas Coratger Date: Tue, 6 Jan 2026 00:31:06 +0100 Subject: [PATCH 4/4] cleanup --- tests/lean_spec/subspecs/networking/test_discovery.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/lean_spec/subspecs/networking/test_discovery.py b/tests/lean_spec/subspecs/networking/test_discovery.py index 32f03f6a..96fe5260 100644 --- a/tests/lean_spec/subspecs/networking/test_discovery.py +++ b/tests/lean_spec/subspecs/networking/test_discovery.py @@ -1,8 +1,4 @@ -""" -Tests for Discovery v5 Protocol Specification - -Test vectors from: https://github.com/ethereum/devp2p/blob/master/discv5/discv5-wire-test-vectors.md -""" +"""Tests for Discovery v5 Protocol Specification""" from lean_spec.subspecs.networking.support.discovery import ( MAX_REQUEST_ID_LENGTH,