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
3 changes: 2 additions & 1 deletion src/lean_spec/subspecs/networking/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
BlocksByRootResponse,
Status,
)
from .types import DomainType, ProtocolId
from .types import DomainType, ForkDigest, ProtocolId

__all__ = [
"MAX_REQUEST_BLOCKS",
Expand All @@ -31,4 +31,5 @@
"Status",
"DomainType",
"ProtocolId",
"ForkDigest",
]
1 change: 1 addition & 0 deletions src/lean_spec/subspecs/networking/support/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Supporting implementation code for networking."""
22 changes: 22 additions & 0 deletions src/lean_spec/subspecs/networking/support/enr/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
Ethereum Node Records (EIP-778)

References:
----------
- EIP-778: https://eips.ethereum.org/EIPS/eip-778
"""

from . import keys
from .enr import ENR
from .eth2 import FAR_FUTURE_EPOCH, AttestationSubnets, Eth2Data, SyncCommitteeSubnets
from .keys import EnrKey

__all__ = [
"ENR",
"EnrKey",
"keys",
"Eth2Data",
"AttestationSubnets",
"SyncCommitteeSubnets",
"FAR_FUTURE_EPOCH",
]
220 changes: 220 additions & 0 deletions src/lean_spec/subspecs/networking/support/enr/enr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""
Ethereum Node Record (EIP-778)
==============================

ENR is an open format for p2p connectivity information that improves upon
the node discovery v4 protocol by providing:

1. **Flexibility**: Arbitrary key/value pairs for any transport protocol
2. **Cryptographic Agility**: Support for multiple identity schemes
3. **Authoritative Updates**: Sequence numbers to determine record freshness

Record Structure
----------------

An ENR is an RLP-encoded list::

record = [signature, seq, k1, v1, k2, v2, ...]

Where:
- `signature`: 64-byte secp256k1 signature (r || s, no recovery id)
- `seq`: 64-bit sequence number (increases on each update)
- `k, v`: Sorted key/value pairs (keys are lexicographically ordered)

The signature covers the content `[seq, k1, v1, k2, v2, ...]` (excluding itself).

Size Limit
----------

Maximum encoded size is **300 bytes**. This ensures ENRs fit in a single
UDP packet and can be included in size-constrained protocols like DNS.

Text Encoding
-------------

Text form is URL-safe base64 with `enr:` prefix::

enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjz...

"v4" Identity Scheme
--------------------

The default scheme uses secp256k1:
- **Sign**: keccak256(content), then secp256k1 signature
- **Verify**: Check signature against `secp256k1` key in record
- **Node ID**: keccak256(uncompressed_public_key)

References:
----------
- EIP-778: https://eips.ethereum.org/EIPS/eip-778
"""

from typing import ClassVar, Optional

from lean_spec.subspecs.networking.types import Multiaddr, NodeId, SeqNumber
from lean_spec.types import StrictBaseModel

from . import keys
from .eth2 import AttestationSubnets, Eth2Data
from .keys import EnrKey


class ENR(StrictBaseModel):
r"""
Ethereum Node Record (EIP-778).

Example from EIP-778 (IPv4 127.0.0.1, UDP 30303)::

enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04j...

Which decodes to RLP::

[
7098ad865b00a582..., # signature (64 bytes)
01, # seq = 1
"id", "v4",
"ip", 7f000001, # 127.0.0.1
"secp256k1", 03ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd3138,
"udp", 765f, # 30303
]
"""

MAX_SIZE: ClassVar[int] = 300
"""Maximum RLP-encoded size in bytes (EIP-778)."""

SCHEME: ClassVar[str] = "v4"
"""Supported identity scheme."""

signature: bytes
"""64-byte secp256k1 signature (r || s concatenated, no recovery id)."""

seq: SeqNumber
"""Sequence number. MUST increase on any record change."""

pairs: dict[EnrKey, bytes]
"""Key/value pairs. Keys must be unique and sorted lexicographically."""

node_id: Optional[NodeId] = None
"""32-byte node ID derived from public key via keccak256."""

def get(self, key: EnrKey) -> Optional[bytes]:
"""Get value by key, or None if absent."""
return self.pairs.get(key)

def has(self, key: EnrKey) -> bool:
"""Check if key is present."""
return key in self.pairs

@property
def identity_scheme(self) -> Optional[str]:
"""Get identity scheme (should be "v4")."""
id_bytes = self.get(keys.ID)
return id_bytes.decode("utf-8") if id_bytes else None

@property
def public_key(self) -> Optional[bytes]:
"""Get compressed secp256k1 public key (33 bytes)."""
return self.get(keys.SECP256K1)

@property
def ip4(self) -> Optional[str]:
"""IPv4 address as dotted string (e.g., "127.0.0.1")."""
ip_bytes = self.get(keys.IP)
return ".".join(str(b) for b in ip_bytes) if ip_bytes and len(ip_bytes) == 4 else None

@property
def ip6(self) -> Optional[str]:
"""IPv6 address as colon-separated hex."""
ip_bytes = self.get(keys.IP6)
if ip_bytes and len(ip_bytes) == 16:
return ":".join(ip_bytes[i : i + 2].hex() for i in range(0, 16, 2))
return None

@property
def tcp_port(self) -> Optional[int]:
"""TCP port (applies to both IPv4 and IPv6 unless tcp6 is set)."""
port = self.get(keys.TCP)
return int.from_bytes(port, "big") if port else None

@property
def udp_port(self) -> Optional[int]:
"""UDP port for discovery (applies to both unless udp6 is set)."""
port = self.get(keys.UDP)
return int.from_bytes(port, "big") if port else None

def multiaddr(self) -> Optional[Multiaddr]:
"""Construct multiaddress from endpoint info."""
if self.ip4 and self.tcp_port:
return f"/ip4/{self.ip4}/tcp/{self.tcp_port}"
if self.ip6 and self.tcp_port:
return f"/ip6/{self.ip6}/tcp/{self.tcp_port}"
return None

# =========================================================================
# Ethereum Consensus Extensions
# =========================================================================

@property
def eth2_data(self) -> Optional[Eth2Data]:
"""Parse eth2 key: fork_digest(4) + next_fork_version(4) + next_fork_epoch(8)."""
eth2_bytes = self.get(keys.ETH2)
if eth2_bytes and len(eth2_bytes) >= 16:
from lean_spec.types import Uint64
from lean_spec.types.byte_arrays import Bytes4

return Eth2Data(
fork_digest=Bytes4(eth2_bytes[0:4]),
next_fork_version=Bytes4(eth2_bytes[4:8]),
next_fork_epoch=Uint64(int.from_bytes(eth2_bytes[8:16], "little")),
)
return None

@property
def attestation_subnets(self) -> Optional[AttestationSubnets]:
"""Parse attnets key (SSZ Bitvector[64])."""
attnets = self.get(keys.ATTNETS)
return AttestationSubnets.decode_bytes(attnets) if attnets and len(attnets) == 8 else None

# =========================================================================
# Validation
# =========================================================================

def is_valid(self) -> bool:
"""
Check structural validity (does NOT verify cryptographic signature).

A valid ENR has:
- Identity scheme "v4"
- 33-byte compressed secp256k1 public key
- 64-byte signature
"""
return (
self.identity_scheme == self.SCHEME
and self.public_key is not None
and len(self.public_key) == 33
and len(self.signature) == 64
)

def is_compatible_with(self, other: "ENR") -> bool:
"""Check fork compatibility via eth2 fork digest."""
self_eth2, other_eth2 = self.eth2_data, other.eth2_data
if self_eth2 is None or other_eth2 is None:
return False
return self_eth2.fork_digest == other_eth2.fork_digest

# =========================================================================
# Display
# =========================================================================

def __str__(self) -> str:
"""Human-readable summary."""
parts = [f"ENR(seq={self.seq}"]
if self.ip4:
parts.append(f"ip={self.ip4}")
if self.tcp_port:
parts.append(f"tcp={self.tcp_port}")
if self.udp_port:
parts.append(f"udp={self.udp_port}")
if eth2 := self.eth2_data:
parts.append(f"fork={eth2.fork_digest.hex()}")
return ", ".join(parts) + ")"
133 changes: 133 additions & 0 deletions src/lean_spec/subspecs/networking/support/enr/eth2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""
Ethereum Consensus ENR Extensions
=================================

Ethereum consensus clients extend ENR with additional keys for fork
compatibility and subnet discovery.

eth2 Key Structure
------------------

The `eth2` key contains 16 bytes::

fork_digest (4 bytes) - Current fork identifier
next_fork_version (4 bytes) - Version of next scheduled fork
next_fork_epoch (8 bytes) - Epoch when next fork activates (little-endian)

attnets / syncnets
------------------

SSZ Bitvectors indicating subnet subscriptions:
- attnets: Bitvector[64] - attestation subnets (bit i = subscribed to subnet i)
- syncnets: Bitvector[4] - sync committee subnets

See: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md
"""

from typing import ClassVar

from lean_spec.subspecs.networking.types import ForkDigest
from lean_spec.types import StrictBaseModel, Uint64
from lean_spec.types.bitfields import BaseBitvector
from lean_spec.types.boolean import Boolean

FAR_FUTURE_EPOCH = Uint64(2**64 - 1)
"""Sentinel value indicating no scheduled fork."""


class Eth2Data(StrictBaseModel):
"""
Ethereum consensus data stored in ENR `eth2` key (16 bytes).

SSZ: fork_digest (4) + next_fork_version (4) + next_fork_epoch (8)
"""

fork_digest: ForkDigest
"""Current active fork identifier (4 bytes)."""

next_fork_version: ForkDigest
"""Fork version of next scheduled fork. Equals current if none scheduled."""

next_fork_epoch: Uint64
"""Epoch when next fork activates. FAR_FUTURE_EPOCH if none scheduled."""

@classmethod
def no_scheduled_fork(cls, current_digest: ForkDigest) -> "Eth2Data":
"""Create Eth2Data with no scheduled fork."""
return cls(
fork_digest=current_digest,
next_fork_version=current_digest,
next_fork_epoch=FAR_FUTURE_EPOCH,
)


class AttestationSubnets(BaseBitvector):
"""
Attestation subnet subscriptions (ENR `attnets` key).

SSZ Bitvector[64] where bit i indicates subscription to subnet i.
"""

LENGTH: ClassVar[int] = 64
"""64 attestation subnets."""

@classmethod
def none(cls) -> "AttestationSubnets":
"""No subscriptions."""
return cls(data=[Boolean(False)] * 64)

@classmethod
def all(cls) -> "AttestationSubnets":
"""Subscribe to all 64 subnets."""
return cls(data=[Boolean(True)] * 64)

@classmethod
def from_subnet_ids(cls, subnet_ids: list[int]) -> "AttestationSubnets":
"""Subscribe to specific subnets."""
bits = [Boolean(False)] * 64
for sid in subnet_ids:
if not 0 <= sid < 64:
raise ValueError(f"Subnet ID must be 0-63, got {sid}")
bits[sid] = Boolean(True)
return cls(data=bits)

def is_subscribed(self, subnet_id: int) -> bool:
"""Check if subscribed to a subnet."""
if not 0 <= subnet_id < 64:
raise ValueError(f"Subnet ID must be 0-63, got {subnet_id}")
return bool(self.data[subnet_id])

def subscribed_subnets(self) -> list[int]:
"""List of subscribed subnet IDs."""
return [i for i in range(64) if self.data[i]]

def subscription_count(self) -> int:
"""Number of subscribed subnets."""
return sum(1 for b in self.data if b)


class SyncCommitteeSubnets(BaseBitvector):
"""
Sync committee subnet subscriptions (ENR `syncnets` key).

SSZ Bitvector[4] where bit i indicates subscription to sync subnet i.
"""

LENGTH: ClassVar[int] = 4
"""4 sync committee subnets."""

@classmethod
def none(cls) -> "SyncCommitteeSubnets":
"""No subscriptions."""
return cls(data=[Boolean(False)] * 4)

@classmethod
def all(cls) -> "SyncCommitteeSubnets":
"""Subscribe to all 4 subnets."""
return cls(data=[Boolean(True)] * 4)

def is_subscribed(self, subnet_id: int) -> bool:
"""Check if subscribed to a sync subnet."""
if not 0 <= subnet_id < 4:
raise ValueError(f"Sync subnet ID must be 0-3, got {subnet_id}")
return bool(self.data[subnet_id])
Loading
Loading