Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/lean_spec/subspecs/networking/peer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Minimal peer tracking for the Ethereum networking layer."""

from .info import Direction, PeerInfo

__all__ = [
"Direction",
"PeerInfo",
]
60 changes: 60 additions & 0 deletions src/lean_spec/subspecs/networking/peer/info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Peer Information"""

from dataclasses import dataclass, field
from enum import IntEnum, auto
from time import time

from ..types import ConnectionState, Multiaddr, PeerId


class Direction(IntEnum):
"""
Direction of a peer connection.

Indicates whether:
- we initiated the connection (outbound) or
- the peer connected to us (inbound).
"""

INBOUND = auto()
"""Peer initiated the connection to us."""

OUTBOUND = auto()
"""We initiated the connection to the peer."""


@dataclass
class PeerInfo:
"""
Minimal information about a known peer.

Tracks only the essential data needed to manage peer connections:
identity, connection state, direction, and last activity timestamp.

This is intentionally minimal - additional fields (scoring, subnet
subscriptions, protocol metadata) can be added as features are
implemented.
"""

peer_id: PeerId
"""The libp2p peer identifier."""

state: ConnectionState = ConnectionState.DISCONNECTED
"""Current connection state."""

direction: Direction = Direction.OUTBOUND
"""Connection direction (inbound/outbound)."""

address: Multiaddr | None = None
"""Last known network address for this peer."""

last_seen: float = field(default_factory=time)
"""Unix timestamp of last successful interaction."""

def is_connected(self) -> bool:
"""Check if peer has an active connection."""
return self.state == ConnectionState.CONNECTED

def update_last_seen(self) -> None:
"""Update the last seen timestamp to now."""
self.last_seen = time()
1 change: 0 additions & 1 deletion src/lean_spec/subspecs/networking/support/__init__.py

This file was deleted.

75 changes: 69 additions & 6 deletions src/lean_spec/subspecs/networking/types.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,88 @@
"""Networking-related type definitions for the specification."""
"""Networking Types"""

from enum import IntEnum, auto

from lean_spec.types import Uint64
from lean_spec.types.byte_arrays import Bytes4, Bytes32

DomainType = Bytes4
"""A 4-byte value used for domain separation in message-ids."""
"""4-byte domain for message-id isolation in Gossipsub."""

NodeId = Bytes32
"""32-byte node identifier for Discovery v5, derived from ``keccak256(pubkey)``."""

ProtocolId = str
"""A string representing a libp2p protocol ID."""
ForkDigest = Bytes4
"""4-byte fork identifier ensuring network isolation between forks."""

SeqNumber = Uint64
"""Sequence number used in ENR records, metadata, and ping messages."""

SubnetId = Uint64
"""Subnet identifier (0-63) for attestation subnet partitioning."""

ProtocolId = str
"""Libp2p protocol identifier, e.g. ``/eth2/beacon_chain/req/status/1/ssz_snappy``."""

PeerId = str
"""Libp2p peer identifier derived from the node's public key."""

Multiaddr = str
"""Multiaddress string, e.g. ``/ip4/192.168.1.1/tcp/9000``."""

ForkDigest = Bytes4
"""4-byte fork identifier ensuring network isolation between forks."""

class ConnectionState(IntEnum):
"""
Peer connection state machine.

Tracks the lifecycle of a connection to a peer::

DISCONNECTED -> CONNECTING -> CONNECTED -> DISCONNECTING -> DISCONNECTED

These states map directly to libp2p connection events.
"""

DISCONNECTED = auto()
"""No active connection to this peer."""

CONNECTING = auto()
"""TCP/QUIC connection in progress."""

CONNECTED = auto()
"""Transport established, can exchange protocol messages."""

DISCONNECTING = auto()
"""Graceful shutdown in progress (Goodbye sent/received)."""


class GoodbyeReason(IntEnum):
"""
Reason codes for the Goodbye request/response message.

Sent when gracefully disconnecting from a peer to indicate why
the connection is being closed.

**Official codes (from spec):**

+------+---------------------+
| Code | Meaning |
+======+=====================+
| 1 | Client shutdown |
+------+---------------------+
| 2 | Irrelevant network |
+------+---------------------+
| 3 | Fault/error |
+------+---------------------+

References:
-----------
- Goodbye spec: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#goodbye-v1
"""

CLIENT_SHUTDOWN = 1
"""Node is shutting down normally."""

IRRELEVANT_NETWORK = 2
"""Peer is on a different fork or network."""

FAULT_OR_ERROR = 3
"""Generic error detected in peer communication."""
6 changes: 3 additions & 3 deletions tests/lean_spec/subspecs/networking/test_discovery.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for Discovery v5 Protocol Specification"""

from lean_spec.subspecs.networking.support.discovery import (
from lean_spec.subspecs.networking.discovery import (
MAX_REQUEST_ID_LENGTH,
PROTOCOL_ID,
PROTOCOL_VERSION,
Expand All @@ -25,7 +25,7 @@
TalkResp,
WhoAreYouAuthdata,
)
from lean_spec.subspecs.networking.support.discovery.config import (
from lean_spec.subspecs.networking.discovery.config import (
ALPHA,
BOND_EXPIRY_SECS,
BUCKET_COUNT,
Expand All @@ -36,7 +36,7 @@
MIN_PACKET_SIZE,
REQUEST_TIMEOUT_SECS,
)
from lean_spec.subspecs.networking.support.discovery.routing import (
from lean_spec.subspecs.networking.discovery.routing import (
NodeEntry,
log2_distance,
xor_distance,
Expand Down
4 changes: 2 additions & 2 deletions tests/lean_spec/subspecs/networking/test_enr.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import pytest
from pydantic import ValidationError

from lean_spec.subspecs.networking.support.enr import ENR, Eth2Data, keys
from lean_spec.subspecs.networking.support.enr.eth2 import AttestationSubnets
from lean_spec.subspecs.networking.enr import ENR, Eth2Data, keys
from lean_spec.subspecs.networking.enr.eth2 import AttestationSubnets
from lean_spec.types import Uint64
from lean_spec.types.byte_arrays import Bytes4

Expand Down
87 changes: 87 additions & 0 deletions tests/lean_spec/subspecs/networking/test_peer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Tests for minimal peer module."""

from lean_spec.subspecs.networking.peer import Direction, PeerInfo
from lean_spec.subspecs.networking.types import ConnectionState, GoodbyeReason


class TestConnectionState:
"""Tests for ConnectionState enum."""

def test_state_values(self) -> None:
"""ConnectionState has the 4 expected states."""
assert ConnectionState.DISCONNECTED == 1
assert ConnectionState.CONNECTING == 2
assert ConnectionState.CONNECTED == 3
assert ConnectionState.DISCONNECTING == 4


class TestGoodbyeReason:
"""Tests for GoodbyeReason codes."""

def test_official_codes(self) -> None:
"""Official spec codes have correct values."""
assert GoodbyeReason.CLIENT_SHUTDOWN == 1
assert GoodbyeReason.IRRELEVANT_NETWORK == 2
assert GoodbyeReason.FAULT_OR_ERROR == 3


class TestDirection:
"""Tests for Direction enum."""

def test_direction_values(self) -> None:
"""Direction has inbound and outbound."""
assert Direction.INBOUND == 1
assert Direction.OUTBOUND == 2


class TestPeerInfo:
"""Tests for PeerInfo dataclass."""

def test_create_peer_info(self) -> None:
"""PeerInfo can be created with peer ID."""
peer = PeerInfo(peer_id="16Uiu2HAk...")
assert peer.peer_id == "16Uiu2HAk..."
assert peer.state == ConnectionState.DISCONNECTED
assert peer.direction == Direction.OUTBOUND
assert peer.address is None

def test_create_with_all_fields(self) -> None:
"""PeerInfo can be created with all fields."""
peer = PeerInfo(
peer_id="16Uiu2HAk...",
state=ConnectionState.CONNECTED,
direction=Direction.INBOUND,
address="/ip4/192.168.1.1/tcp/9000",
)
assert peer.state == ConnectionState.CONNECTED
assert peer.direction == Direction.INBOUND
assert peer.address == "/ip4/192.168.1.1/tcp/9000"

def test_is_connected(self) -> None:
"""is_connected() returns True only when connected."""
peer = PeerInfo(peer_id="test")

peer.state = ConnectionState.DISCONNECTED
assert not peer.is_connected()

peer.state = ConnectionState.CONNECTING
assert not peer.is_connected()

peer.state = ConnectionState.CONNECTED
assert peer.is_connected()

peer.state = ConnectionState.DISCONNECTING
assert not peer.is_connected()

def test_update_last_seen(self) -> None:
"""update_last_seen() updates timestamp."""
peer = PeerInfo(peer_id="test")
original_time = peer.last_seen

# Small delay to ensure time difference
import time

time.sleep(0.01)

peer.update_last_seen()
assert peer.last_seen > original_time
Loading