diff --git a/src/lean_spec/subspecs/networking/client/event_source.py b/src/lean_spec/subspecs/networking/client/event_source.py index 3e30446b..aa8dbf89 100644 --- a/src/lean_spec/subspecs/networking/client/event_source.py +++ b/src/lean_spec/subspecs/networking/client/event_source.py @@ -117,7 +117,12 @@ GossipsubMessageEvent, ) from lean_spec.subspecs.networking.gossipsub.parameters import GossipsubParameters -from lean_spec.subspecs.networking.gossipsub.topic import GossipTopic, TopicKind +from lean_spec.subspecs.networking.gossipsub.topic import ( + ForkMismatchError, + GossipTopic, + TopicKind, +) +from lean_spec.subspecs.networking.peer import PeerInfo from lean_spec.subspecs.networking.reqresp.handler import ( REQRESP_PROTOCOL_IDS, BlockLookup, @@ -347,13 +352,15 @@ def decode_message( Raises: GossipMessageError: If the message cannot be decoded. """ - # Step 1: Parse topic to determine message type. + # Step 1: Parse topic and validate fork compatibility. # # The topic string contains the fork digest and message kind. - # Invalid topics are rejected before any decompression work. - # This prevents wasting CPU on malformed messages. + # Invalid topics and wrong-fork messages are rejected before decompression. + # This prevents wasting CPU on malformed or incompatible messages. try: - topic = GossipTopic.from_string(topic_str) + topic = GossipTopic.from_string_validated(topic_str, self.fork_digest) + except ForkMismatchError: + raise # Re-raise ForkMismatchError without wrapping except ValueError as e: raise GossipMessageError(f"Invalid topic: {e}") from e @@ -399,10 +406,13 @@ def get_topic(self, topic_str: str) -> GossipTopic: Parsed GossipTopic. Raises: + ForkMismatchError: If the topic's fork_digest doesn't match. GossipMessageError: If the topic is invalid. """ try: - return GossipTopic.from_string(topic_str) + return GossipTopic.from_string_validated(topic_str, self.fork_digest) + except ForkMismatchError: + raise # Re-raise ForkMismatchError without wrapping except ValueError as e: raise GossipMessageError(f"Invalid topic: {e}") from e @@ -617,6 +627,12 @@ class LiveNetworkEventSource: Supports both yamux (TCP) and QUIC connection types. """ + _peer_info: dict[PeerId, PeerInfo] = field(default_factory=dict) + """Cache of peer information including status and ENR. + + Populated after status exchange. Used for fork compatibility checks. + """ + _our_status: Status | None = None """Our current chain status for handshakes. @@ -737,6 +753,18 @@ def set_block_lookup(self, lookup: BlockLookup) -> None: """ self._reqresp_handler.block_lookup = lookup + def get_peer_info(self, peer_id: PeerId) -> PeerInfo | None: + """ + Get cached peer info. + + Args: + peer_id: Peer identifier. + + Returns: + PeerInfo if cached, None otherwise. + """ + return self._peer_info.get(peer_id) + def subscribe_gossip_topic(self, topic: str) -> None: """ Subscribe to a gossip topic. @@ -805,6 +833,8 @@ async def _handle_gossipsub_message(self, event: GossipsubMessageEvent) -> None: logger.debug("Processed gossipsub message %s from %s", topic.kind.value, event.peer_id) + except ForkMismatchError as e: + logger.warning("Rejected gossip from wrong fork: %s", e) except GossipMessageError as e: logger.warning("Failed to process gossipsub message: %s", e) @@ -1041,6 +1071,12 @@ async def _exchange_status( peer_status = await self.reqresp_client.send_status(peer_id, self._our_status) if peer_status is not None: + # Cache peer's status for fork compatibility checks. + if peer_id not in self._peer_info: + self._peer_info[peer_id] = PeerInfo(peer_id=peer_id) + self._peer_info[peer_id].status = peer_status + self._peer_info[peer_id].update_last_seen() + await self._events.put(PeerStatusEvent(peer_id=peer_id, status=peer_status)) logger.debug( "Received status from %s: head=%s", @@ -1089,6 +1125,7 @@ async def disconnect(self, peer_id: PeerId) -> None: peer_id: Peer to disconnect. """ conn = self._connections.pop(peer_id, None) + self._peer_info.pop(peer_id, None) # Clean up peer info cache if conn is not None: self.reqresp_client.unregister_connection(peer_id) await conn.close() diff --git a/src/lean_spec/subspecs/networking/discovery/routing.py b/src/lean_spec/subspecs/networking/discovery/routing.py index 5695d779..f6a42f24 100644 --- a/src/lean_spec/subspecs/networking/discovery/routing.py +++ b/src/lean_spec/subspecs/networking/discovery/routing.py @@ -51,13 +51,16 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Iterator +from typing import TYPE_CHECKING, Iterator -from lean_spec.subspecs.networking.types import NodeId, SeqNumber +from lean_spec.subspecs.networking.types import ForkDigest, NodeId, SeqNumber from .config import BUCKET_COUNT, K_BUCKET_SIZE from .messages import Distance +if TYPE_CHECKING: + from lean_spec.subspecs.networking.enr import ENR + def xor_distance(a: NodeId, b: NodeId) -> int: """ @@ -130,6 +133,9 @@ class NodeEntry: verified: bool = False """True if node has responded to at least one PING. Required for relay.""" + enr: ENR | None = None + """Full ENR record. Contains fork data for compatibility checks.""" + @dataclass class KBucket: @@ -250,6 +256,12 @@ class RoutingTable: Organizes nodes into 256 k-buckets by XOR distance from the local node. Bucket i contains nodes with log2(distance) == i + 1. + Fork Filtering + -------------- + When local_fork_digest is set, only peers with matching fork_digest + in their eth2 ENR data are accepted. This prevents storing peers + on different forks. + Lookup Algorithm ---------------- A 'lookup' locates the k closest nodes to a target ID. The initiator @@ -274,6 +286,9 @@ class RoutingTable: buckets: list[KBucket] = field(default_factory=lambda: [KBucket() for _ in range(BUCKET_COUNT)]) """256 k-buckets indexed by log2 distance minus one.""" + local_fork_digest: ForkDigest | None = None + """Our fork_digest for filtering incompatible peers. None disables filtering.""" + def bucket_index(self, node_id: NodeId) -> int: """ Get bucket index for a node ID. @@ -293,19 +308,52 @@ 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 is_fork_compatible(self, entry: NodeEntry) -> bool: + """ + Check if a node entry is fork-compatible. + + If local_fork_digest is set, the entry must have an ENR with + eth2 data containing the same fork_digest. + + Args: + entry: Node entry to check. + + Returns: + - True if compatible or filtering disabled, + - False if fork_digest mismatch or missing eth2 data. + """ + if self.local_fork_digest is None: + return True + + if entry.enr is None: + return False + + eth2_data = entry.enr.eth2_data + if eth2_data is None: + return False + + return eth2_data.fork_digest == self.local_fork_digest + def add(self, entry: NodeEntry) -> bool: """ Add a node to the routing table. + Rejects nodes that are on incompatible forks when fork filtering + is enabled (local_fork_digest is set). + Args: entry: Node entry to add. Returns: - True if added/updated, - - False if bucket full or adding self. + - False if bucket full, adding self, or fork incompatible. """ if entry.node_id == self.local_id: return False + + if not self.is_fork_compatible(entry): + return False + return self.get_bucket(entry.node_id).add(entry) def remove(self, node_id: NodeId) -> bool: diff --git a/src/lean_spec/subspecs/networking/gossipsub/__init__.py b/src/lean_spec/subspecs/networking/gossipsub/__init__.py index 5fb559f3..ca8c368b 100644 --- a/src/lean_spec/subspecs/networking/gossipsub/__init__.py +++ b/src/lean_spec/subspecs/networking/gossipsub/__init__.py @@ -90,6 +90,7 @@ BLOCK_TOPIC_NAME, ENCODING_POSTFIX, TOPIC_PREFIX, + ForkMismatchError, GossipTopic, TopicKind, format_topic_string, @@ -116,6 +117,7 @@ "ENCODING_POSTFIX", "BLOCK_TOPIC_NAME", "ATTESTATION_TOPIC_NAME", + "ForkMismatchError", "format_topic_string", "parse_topic_string", # Parameters diff --git a/src/lean_spec/subspecs/networking/gossipsub/topic.py b/src/lean_spec/subspecs/networking/gossipsub/topic.py index 0bb2040b..dda9438d 100644 --- a/src/lean_spec/subspecs/networking/gossipsub/topic.py +++ b/src/lean_spec/subspecs/networking/gossipsub/topic.py @@ -61,6 +61,17 @@ from dataclasses import dataclass from enum import Enum + +class ForkMismatchError(ValueError): + """Raised when a topic's fork_digest does not match the expected value.""" + + def __init__(self, expected: str, actual: str) -> None: + """Initialize with expected and actual fork digests.""" + self.expected = expected + self.actual = actual + super().__init__(f"Fork mismatch: expected {expected}, got {actual}") + + TOPIC_PREFIX: str = "leanconsensus" """Network prefix for Lean consensus gossip topics. @@ -146,6 +157,31 @@ def __bytes__(self) -> bytes: """ return str(self).encode("utf-8") + def validate_fork(self, expected_fork_digest: str) -> None: + """ + Validate that the topic's fork_digest matches expected. + + Args: + expected_fork_digest: Expected fork digest (0x-prefixed hex). + + Raises: + ForkMismatchError: If fork_digest does not match. + """ + if self.fork_digest != expected_fork_digest: + raise ForkMismatchError(expected_fork_digest, self.fork_digest) + + def is_fork_compatible(self, expected_fork_digest: str) -> bool: + """ + Check if this topic is compatible with the expected fork. + + Args: + expected_fork_digest: Expected fork digest (0x-prefixed hex). + + Returns: + True if fork_digest matches, False otherwise. + """ + return self.fork_digest == expected_fork_digest + @classmethod def from_string(cls, topic_str: str) -> GossipTopic: """Parse a topic string into a GossipTopic. @@ -183,6 +219,28 @@ def from_string(cls, topic_str: str) -> GossipTopic: return cls(kind=kind, fork_digest=fork_digest) + @classmethod + def from_string_validated(cls, topic_str: str, expected_fork_digest: str) -> GossipTopic: + """Parse a topic string and validate fork compatibility. + + Combines parsing and fork validation into a single operation. + Use this when receiving gossip messages to reject wrong-fork topics early. + + Args: + topic_str: Full topic string to parse. + expected_fork_digest: Expected fork digest (0x-prefixed hex). + + Returns: + Parsed GossipTopic instance. + + Raises: + ValueError: If the topic string is malformed. + ForkMismatchError: If fork_digest does not match expected. + """ + topic = cls.from_string(topic_str) + topic.validate_fork(expected_fork_digest) + return topic + @classmethod def block(cls, fork_digest: str) -> GossipTopic: """Create a block topic for the given fork. diff --git a/src/lean_spec/subspecs/networking/peer/info.py b/src/lean_spec/subspecs/networking/peer/info.py index b5a7f1bd..b9b0b08b 100644 --- a/src/lean_spec/subspecs/networking/peer/info.py +++ b/src/lean_spec/subspecs/networking/peer/info.py @@ -1,12 +1,19 @@ """Peer Information""" +from __future__ import annotations + from dataclasses import dataclass, field from enum import IntEnum, auto from time import time +from typing import TYPE_CHECKING from ..transport import PeerId from ..types import ConnectionState, Multiaddr +if TYPE_CHECKING: + from ..enr import ENR + from ..reqresp import Status + class Direction(IntEnum): """ @@ -27,14 +34,16 @@ class Direction(IntEnum): @dataclass class PeerInfo: """ - Minimal information about a known peer. + Information about a known peer. + + Tracks identity, connection state, and cached protocol data. + + The enr and status fields cache fork data from discovery and handshake: - Tracks only the essential data needed to manage peer connections: - identity, connection state, direction, and last activity timestamp. + - enr: Populated from discovery, contains eth2 fork_digest + - status: Populated after Status handshake, contains finalized/head checkpoints - This is intentionally minimal - additional fields (scoring, subnet - subscriptions, protocol metadata) can be added as features are - implemented. + These cached values enable fork compatibility checks at multiple layers. """ peer_id: PeerId @@ -52,6 +61,12 @@ class PeerInfo: last_seen: float = field(default_factory=time) """Unix timestamp of last successful interaction.""" + enr: ENR | None = None + """Cached ENR from discovery. Contains eth2 fork_digest for compatibility checks.""" + + status: Status | None = None + """Cached Status from handshake. Contains finalized/head checkpoints.""" + def is_connected(self) -> bool: """Check if peer has an active connection.""" return self.state == ConnectionState.CONNECTED @@ -59,3 +74,18 @@ def is_connected(self) -> bool: def update_last_seen(self) -> None: """Update the last seen timestamp to now.""" self.last_seen = time() + + @property + def fork_digest(self) -> bytes | None: + """ + Get the peer's fork_digest from cached ENR. + + Returns: + 4-byte fork_digest or None if ENR/eth2 data unavailable. + """ + if self.enr is None: + return None + eth2_data = self.enr.eth2_data + if eth2_data is None: + return None + return bytes(eth2_data.fork_digest) diff --git a/src/lean_spec/subspecs/networking/reqresp/__init__.py b/src/lean_spec/subspecs/networking/reqresp/__init__.py index e68e50e0..5bba3609 100644 --- a/src/lean_spec/subspecs/networking/reqresp/__init__.py +++ b/src/lean_spec/subspecs/networking/reqresp/__init__.py @@ -1,10 +1,14 @@ """ReqResp specs for the Lean Ethereum consensus specification.""" from .codec import ( + CONTEXT_BYTES_LENGTH, CodecError, + ForkDigestMismatchError, ResponseCode, decode_request, encode_request, + prepend_context_bytes, + validate_context_bytes, ) from .handler import ( REQRESP_PROTOCOL_IDS, @@ -34,9 +38,14 @@ "Status", # Codec "CodecError", + "ForkDigestMismatchError", "ResponseCode", "encode_request", "decode_request", + # Context bytes + "CONTEXT_BYTES_LENGTH", + "prepend_context_bytes", + "validate_context_bytes", # Inbound handlers "BlockLookup", "DefaultRequestHandler", diff --git a/src/lean_spec/subspecs/networking/reqresp/codec.py b/src/lean_spec/subspecs/networking/reqresp/codec.py index 33a3cbd9..3caa7ad1 100644 --- a/src/lean_spec/subspecs/networking/reqresp/codec.py +++ b/src/lean_spec/subspecs/networking/reqresp/codec.py @@ -92,6 +92,78 @@ class CodecError(Exception): """ +class ForkDigestMismatchError(CodecError): + """Raised when context bytes (fork_digest) do not match expected value. + + Context bytes are 4 bytes prepended to each response chunk in + protocols that return fork-specific data (BlocksByRange, BlobSidecars). + """ + + def __init__(self, expected: bytes, actual: bytes) -> None: + """Initialize with expected and actual fork digests.""" + self.expected = expected + self.actual = actual + super().__init__(f"Fork digest mismatch: expected {expected.hex()}, got {actual.hex()}") + + +CONTEXT_BYTES_LENGTH: int = 4 +"""Length of context bytes (fork_digest) in responses.""" + + +def validate_context_bytes(data: bytes, expected_fork_digest: bytes) -> bytes: + """ + Validate and strip context bytes from a response chunk. + + Some req/resp protocols prepend a 4-byte fork_digest (context bytes) + to each response chunk. This allows clients to verify they're receiving + data for the expected fork. + + Args: + data: Response data with context bytes prepended. + expected_fork_digest: Expected 4-byte fork_digest. + + Returns: + Response data with context bytes stripped. + + Raises: + CodecError: If data is too short to contain context bytes. + ForkDigestMismatchError: If context bytes don't match expected. + """ + if len(data) < CONTEXT_BYTES_LENGTH: + raise CodecError( + f"Response too short for context bytes: {len(data)} < {CONTEXT_BYTES_LENGTH}" + ) + + context_bytes = data[:CONTEXT_BYTES_LENGTH] + if context_bytes != expected_fork_digest: + raise ForkDigestMismatchError(expected_fork_digest, context_bytes) + + return data[CONTEXT_BYTES_LENGTH:] + + +def prepend_context_bytes(data: bytes, fork_digest: bytes) -> bytes: + """ + Prepend context bytes (fork_digest) to a response chunk. + + Used when sending responses for protocols that require context bytes. + + Args: + data: Response data to send. + fork_digest: 4-byte fork_digest to prepend. + + Returns: + Response data with context bytes prepended. + + Raises: + ValueError: If fork_digest is not exactly 4 bytes. + """ + if len(fork_digest) != CONTEXT_BYTES_LENGTH: + raise ValueError( + f"Fork digest must be {CONTEXT_BYTES_LENGTH} bytes, got {len(fork_digest)}" + ) + return fork_digest + data + + def encode_request(ssz_data: bytes) -> bytes: """ Encode an SSZ-serialized request for transmission. diff --git a/tests/lean_spec/subspecs/networking/test_discovery.py b/tests/lean_spec/subspecs/networking/test_discovery.py index cf7d2e74..50f67249 100644 --- a/tests/lean_spec/subspecs/networking/test_discovery.py +++ b/tests/lean_spec/subspecs/networking/test_discovery.py @@ -1,5 +1,7 @@ """Tests for Discovery v5 Protocol Specification""" +from typing import TYPE_CHECKING + from lean_spec.subspecs.networking.discovery import ( MAX_REQUEST_ID_LENGTH, PROTOCOL_ID, @@ -44,6 +46,9 @@ from lean_spec.subspecs.networking.types import NodeId, SeqNumber from lean_spec.types.uint import Uint8, Uint16, Uint64 +if TYPE_CHECKING: + from lean_spec.subspecs.networking.enr import ENR + class TestProtocolConstants: """Verify protocol constants match Discovery v5 specification.""" @@ -690,6 +695,99 @@ def test_nodes_at_invalid_distance(self) -> None: assert table.nodes_at_distance(Distance(257)) == [] +class TestRoutingTableForkFiltering: + """Tests for routing table fork compatibility filtering.""" + + def _make_enr_with_eth2(self, fork_digest_hex: str) -> "ENR": + """Create a minimal ENR with eth2 data for testing.""" + from lean_spec.subspecs.networking.enr import ENR + from lean_spec.subspecs.networking.enr.eth2 import FAR_FUTURE_EPOCH + from lean_spec.types.byte_arrays import Bytes4 + + # Create eth2 bytes: fork_digest(4) + next_fork_version(4) + next_fork_epoch(8) + fork_digest = Bytes4(bytes.fromhex(fork_digest_hex)) + eth2_bytes = ( + bytes(fork_digest) + bytes(fork_digest) + int(FAR_FUTURE_EPOCH).to_bytes(8, "little") + ) + enr = ENR( + signature=b"\x00" * 64, + seq=SeqNumber(1), + pairs={"eth2": eth2_bytes, "id": b"v4"}, + ) + return enr + + def test_no_filtering_without_local_fork_digest(self) -> None: + """Nodes are accepted when local_fork_digest is not set.""" + local_id = NodeId(b"\x00" * 32) + table = RoutingTable(local_id=local_id) # No fork_digest + + entry = NodeEntry(node_id=NodeId(b"\x01" * 32)) # No ENR + assert table.add(entry) + assert table.contains(entry.node_id) + + def test_filtering_rejects_node_without_enr(self) -> None: + """Node without ENR is rejected when fork filtering is enabled.""" + from lean_spec.types.byte_arrays import Bytes4 + + local_id = NodeId(b"\x00" * 32) + fork_digest = Bytes4(bytes.fromhex("12345678")) + table = RoutingTable(local_id=local_id, local_fork_digest=fork_digest) + + entry = NodeEntry(node_id=NodeId(b"\x01" * 32)) # No ENR + assert not table.add(entry) + assert not table.contains(entry.node_id) + + def test_filtering_rejects_mismatched_fork(self) -> None: + """Node with different fork_digest is rejected.""" + from lean_spec.types.byte_arrays import Bytes4 + + local_id = NodeId(b"\x00" * 32) + local_fork = Bytes4(bytes.fromhex("12345678")) + table = RoutingTable(local_id=local_id, local_fork_digest=local_fork) + + enr = self._make_enr_with_eth2("deadbeef") # Different fork + entry = NodeEntry(node_id=NodeId(b"\x01" * 32), enr=enr) + + assert not table.add(entry) + assert not table.contains(entry.node_id) + + def test_filtering_accepts_matching_fork(self) -> None: + """Node with matching fork_digest is accepted.""" + from lean_spec.types.byte_arrays import Bytes4 + + local_id = NodeId(b"\x00" * 32) + local_fork = Bytes4(bytes.fromhex("12345678")) + table = RoutingTable(local_id=local_id, local_fork_digest=local_fork) + + enr = self._make_enr_with_eth2("12345678") # Same fork + entry = NodeEntry(node_id=NodeId(b"\x01" * 32), enr=enr) + + assert table.add(entry) + assert table.contains(entry.node_id) + + def test_is_fork_compatible_method(self) -> None: + """Test is_fork_compatible method directly.""" + from lean_spec.types.byte_arrays import Bytes4 + + local_id = NodeId(b"\x00" * 32) + local_fork = Bytes4(bytes.fromhex("12345678")) + table = RoutingTable(local_id=local_id, local_fork_digest=local_fork) + + # Compatible entry + compatible_enr = self._make_enr_with_eth2("12345678") + compatible_entry = NodeEntry(node_id=NodeId(b"\x01" * 32), enr=compatible_enr) + assert table.is_fork_compatible(compatible_entry) + + # Incompatible entry (different fork) + incompatible_enr = self._make_enr_with_eth2("deadbeef") + incompatible_entry = NodeEntry(node_id=NodeId(b"\x02" * 32), enr=incompatible_enr) + assert not table.is_fork_compatible(incompatible_entry) + + # Entry without ENR + no_enr_entry = NodeEntry(node_id=NodeId(b"\x03" * 32)) + assert not table.is_fork_compatible(no_enr_entry) + + class TestNodeEntry: """Tests for NodeEntry dataclass.""" @@ -702,6 +800,7 @@ def test_default_values(self) -> None: assert entry.last_seen == 0.0 assert entry.endpoint is None assert entry.verified is False + assert entry.enr is None def test_full_construction(self) -> None: """NodeEntry accepts all fields.""" diff --git a/tests/lean_spec/subspecs/networking/test_gossipsub.py b/tests/lean_spec/subspecs/networking/test_gossipsub.py index 5d2b1051..eafbe787 100644 --- a/tests/lean_spec/subspecs/networking/test_gossipsub.py +++ b/tests/lean_spec/subspecs/networking/test_gossipsub.py @@ -10,6 +10,7 @@ from lean_spec.subspecs.networking.gossipsub import ( ControlMessage, FanoutEntry, + ForkMismatchError, GossipsubMessage, GossipsubParameters, GossipTopic, @@ -190,6 +191,60 @@ def test_control_message_empty_check(self) -> None: assert not non_empty.is_empty() +class TestTopicForkValidation: + """Test suite for topic fork compatibility validation.""" + + def test_is_fork_compatible_matching(self) -> None: + """Test is_fork_compatible returns True for matching fork_digest.""" + topic = GossipTopic(kind=TopicKind.BLOCK, fork_digest="0x12345678") + assert topic.is_fork_compatible("0x12345678") + + def test_is_fork_compatible_mismatched(self) -> None: + """Test is_fork_compatible returns False for mismatched fork_digest.""" + topic = GossipTopic(kind=TopicKind.BLOCK, fork_digest="0x12345678") + assert not topic.is_fork_compatible("0xdeadbeef") + + def test_validate_fork_success(self) -> None: + """Test validate_fork passes for matching fork_digest.""" + topic = GossipTopic(kind=TopicKind.BLOCK, fork_digest="0x12345678") + topic.validate_fork("0x12345678") # Should not raise + + def test_validate_fork_raises_on_mismatch(self) -> None: + """Test validate_fork raises ForkMismatchError on mismatch.""" + from lean_spec.subspecs.networking.gossipsub import ForkMismatchError + + topic = GossipTopic(kind=TopicKind.BLOCK, fork_digest="0x12345678") + with pytest.raises(ForkMismatchError) as exc_info: + topic.validate_fork("0xdeadbeef") + + assert exc_info.value.expected == "0xdeadbeef" + assert exc_info.value.actual == "0x12345678" + + def test_from_string_validated_success(self) -> None: + """Test from_string_validated parses and validates successfully.""" + topic = GossipTopic.from_string_validated( + "/leanconsensus/0x12345678/block/ssz_snappy", + expected_fork_digest="0x12345678", + ) + assert topic.kind == TopicKind.BLOCK + assert topic.fork_digest == "0x12345678" + + def test_from_string_validated_raises_on_mismatch(self) -> None: + """Test from_string_validated raises ForkMismatchError on mismatch.""" + from lean_spec.subspecs.networking.gossipsub import ForkMismatchError + + with pytest.raises(ForkMismatchError): + GossipTopic.from_string_validated( + "/leanconsensus/0x12345678/block/ssz_snappy", + expected_fork_digest="0xdeadbeef", + ) + + def test_from_string_validated_raises_on_invalid_topic(self) -> None: + """Test from_string_validated raises ValueError for invalid topics.""" + with pytest.raises(ValueError, match="expected 4 parts"): + GossipTopic.from_string_validated("/invalid/topic", "0x12345678") + + class TestTopicFormatting: """Test suite for topic string formatting and parsing.""" @@ -804,3 +859,51 @@ def test_large_message_encoding(self) -> None: assert len(decoded.publish) == 1 assert len(decoded.publish[0].data) == 100_000 assert decoded.publish[0].data == large_data + + +class TestGossipHandlerForkValidation: + """Test suite for GossipHandler fork compatibility validation.""" + + def test_decode_message_rejects_wrong_fork(self) -> None: + """GossipHandler.decode_message() raises ForkMismatchError for wrong fork.""" + from lean_spec.subspecs.networking.client.event_source import GossipHandler + + handler = GossipHandler(fork_digest="0x12345678") + + # Topic with different fork_digest + wrong_fork_topic = "/leanconsensus/0xdeadbeef/block/ssz_snappy" + + with pytest.raises(ForkMismatchError) as exc_info: + handler.decode_message(wrong_fork_topic, b"dummy_data") + + assert exc_info.value.expected == "0x12345678" + assert exc_info.value.actual == "0xdeadbeef" + + def test_get_topic_rejects_wrong_fork(self) -> None: + """GossipHandler.get_topic() raises ForkMismatchError for wrong fork.""" + from lean_spec.subspecs.networking.client.event_source import GossipHandler + + handler = GossipHandler(fork_digest="0x12345678") + + # Topic with different fork_digest + wrong_fork_topic = "/leanconsensus/0xdeadbeef/attestation/ssz_snappy" + + with pytest.raises(ForkMismatchError) as exc_info: + handler.get_topic(wrong_fork_topic) + + assert exc_info.value.expected == "0x12345678" + assert exc_info.value.actual == "0xdeadbeef" + + def test_get_topic_accepts_matching_fork(self) -> None: + """GossipHandler.get_topic() returns topic for matching fork.""" + from lean_spec.subspecs.networking.client.event_source import GossipHandler + + handler = GossipHandler(fork_digest="0x12345678") + + # Topic with matching fork_digest + matching_topic = "/leanconsensus/0x12345678/block/ssz_snappy" + + topic = handler.get_topic(matching_topic) + + assert topic.kind == TopicKind.BLOCK + assert topic.fork_digest == "0x12345678" diff --git a/tests/lean_spec/subspecs/networking/test_peer.py b/tests/lean_spec/subspecs/networking/test_peer.py index e9405a04..4988810c 100644 --- a/tests/lean_spec/subspecs/networking/test_peer.py +++ b/tests/lean_spec/subspecs/networking/test_peer.py @@ -1,9 +1,14 @@ """Tests for minimal peer module.""" +from typing import TYPE_CHECKING + from lean_spec.subspecs.networking import PeerId from lean_spec.subspecs.networking.peer import Direction, PeerInfo from lean_spec.subspecs.networking.types import ConnectionState, GoodbyeReason +if TYPE_CHECKING: + from lean_spec.subspecs.networking.enr import ENR + def peer(name: str) -> PeerId: """Create a PeerId from a test name.""" @@ -92,3 +97,91 @@ def test_update_last_seen(self) -> None: info.update_last_seen() assert info.last_seen > original_time + + +class TestPeerInfoForkDigest: + """Tests for PeerInfo fork_digest property.""" + + def _make_enr_with_eth2(self, fork_digest_bytes: bytes) -> "ENR": + """Create a minimal ENR with eth2 data for testing.""" + from lean_spec.subspecs.networking.enr import ENR + from lean_spec.subspecs.networking.enr.eth2 import FAR_FUTURE_EPOCH + from lean_spec.types import Uint64 + + # Create eth2 bytes: fork_digest(4) + next_fork_version(4) + next_fork_epoch(8) + eth2_bytes = ( + fork_digest_bytes + fork_digest_bytes + int(FAR_FUTURE_EPOCH).to_bytes(8, "little") + ) + return ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={"eth2": eth2_bytes, "id": b"v4"}, + ) + + def test_fork_digest_none_without_enr(self) -> None: + """fork_digest returns None when no ENR is set.""" + info = PeerInfo(peer_id=peer("test")) + assert info.fork_digest is None + + def test_fork_digest_none_without_eth2(self) -> None: + """fork_digest returns None when ENR has no eth2 data.""" + from lean_spec.subspecs.networking.enr import ENR + from lean_spec.types import Uint64 + + # ENR without eth2 key + enr = ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={"id": b"v4"}, + ) + info = PeerInfo(peer_id=peer("test"), enr=enr) + assert info.fork_digest is None + + def test_fork_digest_returns_bytes(self) -> None: + """fork_digest returns 4-byte fork_digest from ENR eth2 data.""" + fork_bytes = b"\x12\x34\x56\x78" + enr = self._make_enr_with_eth2(fork_bytes) + info = PeerInfo(peer_id=peer("test"), enr=enr) + + assert info.fork_digest == fork_bytes + + def test_enr_and_status_fields(self) -> None: + """Test that enr and status fields exist and default to None.""" + info = PeerInfo(peer_id=peer("test")) + assert info.enr is None + assert info.status is None + + def test_status_can_be_set(self) -> None: + """Test that status can be set and read back.""" + from lean_spec.subspecs.containers.checkpoint import Checkpoint + from lean_spec.subspecs.containers.slot import Slot + from lean_spec.subspecs.networking.reqresp import Status + from lean_spec.types import Bytes32 + + info = PeerInfo(peer_id=peer("test")) + + # Create a test status + test_checkpoint = Checkpoint(root=Bytes32(b"\x00" * 32), slot=Slot(100)) + test_status = Status( + finalized=test_checkpoint, + head=Checkpoint(root=Bytes32(b"\x01" * 32), slot=Slot(200)), + ) + + # Set status + info.status = test_status + assert info.status is not None + assert info.status.finalized.slot == Slot(100) + assert info.status.head.slot == Slot(200) + + def test_update_last_seen_updates_timestamp(self) -> None: + """Test that update_last_seen updates the last_seen timestamp.""" + import time + + info = PeerInfo(peer_id=peer("test")) + original_time = info.last_seen + + # Brief delay + time.sleep(0.01) + + info.update_last_seen() + assert info.last_seen > original_time diff --git a/tests/lean_spec/subspecs/networking/test_reqresp.py b/tests/lean_spec/subspecs/networking/test_reqresp.py index b84635bd..08735233 100644 --- a/tests/lean_spec/subspecs/networking/test_reqresp.py +++ b/tests/lean_spec/subspecs/networking/test_reqresp.py @@ -603,3 +603,120 @@ def test_incompressible_data(self) -> None: encoded = encode_request(incompressible) decoded = decode_request(encoded) assert decoded == incompressible + + +class TestContextBytesValidation: + """Tests for context bytes (fork_digest) validation in responses. + + Some req/resp protocols prepend a 4-byte fork_digest to each response + chunk. This allows clients to verify they're receiving data for the + expected fork. + + Reference: Ethereum P2P Interface Spec + https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/p2p-interface.md + """ + + def test_validate_context_bytes_success(self) -> None: + """Valid context bytes are validated and stripped.""" + from lean_spec.subspecs.networking.reqresp import validate_context_bytes + + fork_digest = b"\x12\x34\x56\x78" + payload = b"block data here" + data = fork_digest + payload + + result = validate_context_bytes(data, fork_digest) + assert result == payload + + def test_validate_context_bytes_mismatch(self) -> None: + """Mismatched context bytes raise ForkDigestMismatchError.""" + from lean_spec.subspecs.networking.reqresp import ( + ForkDigestMismatchError, + validate_context_bytes, + ) + + expected_fork = b"\x12\x34\x56\x78" + actual_fork = b"\xde\xad\xbe\xef" + payload = b"block data" + data = actual_fork + payload + + with pytest.raises(ForkDigestMismatchError) as exc_info: + validate_context_bytes(data, expected_fork) + + assert exc_info.value.expected == expected_fork + assert exc_info.value.actual == actual_fork + + def test_validate_context_bytes_too_short(self) -> None: + """Data shorter than context bytes raises CodecError.""" + from lean_spec.subspecs.networking.reqresp import validate_context_bytes + + fork_digest = b"\x12\x34\x56\x78" + too_short = b"\x12\x34" # Only 2 bytes + + with pytest.raises(CodecError, match="too short"): + validate_context_bytes(too_short, fork_digest) + + def test_validate_context_bytes_exactly_4_bytes(self) -> None: + """Data of exactly 4 bytes (context only, no payload) works.""" + from lean_spec.subspecs.networking.reqresp import validate_context_bytes + + fork_digest = b"\x12\x34\x56\x78" + data = fork_digest # No payload, just context bytes + + result = validate_context_bytes(data, fork_digest) + assert result == b"" + + def test_prepend_context_bytes(self) -> None: + """Context bytes are correctly prepended to payload.""" + from lean_spec.subspecs.networking.reqresp import prepend_context_bytes + + fork_digest = b"\x12\x34\x56\x78" + payload = b"block data" + + result = prepend_context_bytes(payload, fork_digest) + assert result == fork_digest + payload + assert len(result) == len(fork_digest) + len(payload) + + def test_prepend_context_bytes_wrong_length(self) -> None: + """Prepending context bytes with wrong length raises ValueError.""" + from lean_spec.subspecs.networking.reqresp import prepend_context_bytes + + invalid_fork = b"\x12\x34\x56" # Only 3 bytes + payload = b"block data" + + with pytest.raises(ValueError, match="4 bytes"): + prepend_context_bytes(payload, invalid_fork) + + def test_context_bytes_roundtrip(self) -> None: + """Prepend and validate context bytes roundtrip.""" + from lean_spec.subspecs.networking.reqresp import ( + prepend_context_bytes, + validate_context_bytes, + ) + + fork_digest = b"\xab\xcd\xef\x01" + original_payload = b"some response data" + + # Prepend context bytes (sender side) + with_context = prepend_context_bytes(original_payload, fork_digest) + + # Validate and strip context bytes (receiver side) + recovered_payload = validate_context_bytes(with_context, fork_digest) + + assert recovered_payload == original_payload + + def test_fork_digest_mismatch_error_message(self) -> None: + """ForkDigestMismatchError has informative message.""" + from lean_spec.subspecs.networking.reqresp import ForkDigestMismatchError + + expected = b"\x12\x34\x56\x78" + actual = b"\xde\xad\xbe\xef" + + error = ForkDigestMismatchError(expected, actual) + assert "12345678" in str(error) + assert "deadbeef" in str(error) + + def test_context_bytes_length_constant(self) -> None: + """CONTEXT_BYTES_LENGTH constant is 4.""" + from lean_spec.subspecs.networking.reqresp import CONTEXT_BYTES_LENGTH + + assert CONTEXT_BYTES_LENGTH == 4