diff --git a/src/lean_spec/subspecs/networking/__init__.py b/src/lean_spec/subspecs/networking/__init__.py index c9122e3b..95b6426c 100644 --- a/src/lean_spec/subspecs/networking/__init__.py +++ b/src/lean_spec/subspecs/networking/__init__.py @@ -15,7 +15,7 @@ BlocksByRootResponse, Status, ) -from .types import DomainType, ProtocolId +from .types import DomainType, ForkDigest, ProtocolId __all__ = [ "MAX_REQUEST_BLOCKS", @@ -31,4 +31,5 @@ "Status", "DomainType", "ProtocolId", + "ForkDigest", ] diff --git a/src/lean_spec/subspecs/networking/support/__init__.py b/src/lean_spec/subspecs/networking/support/__init__.py new file mode 100644 index 00000000..28ac4be6 --- /dev/null +++ b/src/lean_spec/subspecs/networking/support/__init__.py @@ -0,0 +1 @@ +"""Supporting implementation code for networking.""" diff --git a/src/lean_spec/subspecs/networking/support/enr/__init__.py b/src/lean_spec/subspecs/networking/support/enr/__init__.py new file mode 100644 index 00000000..910f76d3 --- /dev/null +++ b/src/lean_spec/subspecs/networking/support/enr/__init__.py @@ -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", +] diff --git a/src/lean_spec/subspecs/networking/support/enr/enr.py b/src/lean_spec/subspecs/networking/support/enr/enr.py new file mode 100644 index 00000000..afaafb15 --- /dev/null +++ b/src/lean_spec/subspecs/networking/support/enr/enr.py @@ -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) + ")" diff --git a/src/lean_spec/subspecs/networking/support/enr/eth2.py b/src/lean_spec/subspecs/networking/support/enr/eth2.py new file mode 100644 index 00000000..231e344c --- /dev/null +++ b/src/lean_spec/subspecs/networking/support/enr/eth2.py @@ -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]) diff --git a/src/lean_spec/subspecs/networking/support/enr/keys.py b/src/lean_spec/subspecs/networking/support/enr/keys.py new file mode 100644 index 00000000..f45bf719 --- /dev/null +++ b/src/lean_spec/subspecs/networking/support/enr/keys.py @@ -0,0 +1,49 @@ +""" +ENR Key Constants (EIP-778) + +ENR keys can be any byte sequence, but ASCII text is preferred. + +These constants define pre-defined keys with standard meanings. + +See: https://eips.ethereum.org/EIPS/eip-778 +""" + +from typing import Final + +EnrKey = str +"""Type alias for ENR keys (can be any string/bytes per EIP-778)""" + +# EIP-778 Standard Keys +ID: Final[EnrKey] = "id" +"""Identity scheme name. Required. Currently only "v4" is defined.""" + +SECP256K1: Final[EnrKey] = "secp256k1" +"""Compressed secp256k1 public key (33 bytes). Required for "v4" scheme.""" + +IP: Final[EnrKey] = "ip" +"""IPv4 address (4 bytes).""" + +TCP: Final[EnrKey] = "tcp" +"""TCP port (big-endian integer).""" + +UDP: Final[EnrKey] = "udp" +"""UDP port for discovery (big-endian integer).""" + +IP6: Final[EnrKey] = "ip6" +"""IPv6 address (16 bytes).""" + +TCP6: Final[EnrKey] = "tcp6" +"""IPv6-specific TCP port.""" + +UDP6: Final[EnrKey] = "udp6" +"""IPv6-specific UDP port.""" + +# Ethereum Consensus Extensions +ETH2: Final[EnrKey] = "eth2" +"""Ethereum consensus fork data (16 bytes).""" + +ATTNETS: Final[EnrKey] = "attnets" +"""Attestation subnet subscriptions (8 bytes bitvector).""" + +SYNCNETS: Final[EnrKey] = "syncnets" +"""Sync committee subnet subscriptions (1 byte bitvector).""" diff --git a/src/lean_spec/subspecs/networking/types.py b/src/lean_spec/subspecs/networking/types.py index 7cf4f12d..40226e16 100644 --- a/src/lean_spec/subspecs/networking/types.py +++ b/src/lean_spec/subspecs/networking/types.py @@ -20,3 +20,6 @@ Multiaddr = str """Multiaddress string, e.g. ``/ip4/192.168.1.1/tcp/9000``.""" + +ForkDigest = Bytes4 +"""4-byte fork identifier ensuring network isolation between forks.""" diff --git a/tests/lean_spec/subspecs/networking/test_enr.py b/tests/lean_spec/subspecs/networking/test_enr.py new file mode 100644 index 00000000..10bda2b2 --- /dev/null +++ b/tests/lean_spec/subspecs/networking/test_enr.py @@ -0,0 +1,265 @@ +"""Tests for Ethereum Node Record (ENR) specification.""" + +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.types import Uint64 +from lean_spec.types.byte_arrays import Bytes4 + + +class TestEnrKeys: + """Tests for ENR key constants.""" + + def test_identity_keys(self) -> None: + """Identity keys have correct values.""" + assert keys.ID == "id" + assert keys.SECP256K1 == "secp256k1" + + def test_network_keys(self) -> None: + """Network keys have correct values.""" + assert keys.IP == "ip" + assert keys.IP6 == "ip6" + assert keys.TCP == "tcp" + assert keys.UDP == "udp" + + def test_ethereum_keys(self) -> None: + """Ethereum-specific keys have correct values.""" + assert keys.ETH2 == "eth2" + assert keys.ATTNETS == "attnets" + assert keys.SYNCNETS == "syncnets" + + +class TestEth2Data: + """Tests for Eth2Data structure.""" + + def test_create_eth2_data(self) -> None: + """Eth2Data can be created with valid parameters.""" + data = Eth2Data( + fork_digest=Bytes4(b"\x12\x34\x56\x78"), + next_fork_version=Bytes4(b"\x02\x00\x00\x00"), + next_fork_epoch=Uint64(194048), + ) + assert data.fork_digest == Bytes4(b"\x12\x34\x56\x78") + assert data.next_fork_epoch == Uint64(194048) + + def test_no_scheduled_fork_factory(self) -> None: + """no_scheduled_fork factory creates correct data.""" + digest = Bytes4(b"\xab\xcd\xef\x01") + data = Eth2Data.no_scheduled_fork(digest) + + assert data.fork_digest == digest + assert data.next_fork_version == digest + assert data.next_fork_epoch == Uint64(2**64 - 1) + + def test_eth2_data_immutable(self) -> None: + """Eth2Data is immutable (frozen).""" + data = Eth2Data( + fork_digest=Bytes4(b"\x12\x34\x56\x78"), + next_fork_version=Bytes4(b"\x02\x00\x00\x00"), + next_fork_epoch=Uint64(0), + ) + with pytest.raises(ValidationError): + data.fork_digest = Bytes4(b"\x00\x00\x00\x00") + + +class TestAttestationSubnets: + """Tests for AttestationSubnets bitvector.""" + + def test_empty_subscriptions(self) -> None: + """none() creates empty subscriptions.""" + subnets = AttestationSubnets.none() + assert subnets.subscription_count() == 0 + assert subnets.subscribed_subnets() == [] + + def test_all_subscriptions(self) -> None: + """all() creates full subscriptions.""" + subnets = AttestationSubnets.all() + assert subnets.subscription_count() == 64 + assert len(subnets.subscribed_subnets()) == 64 + + def test_specific_subscriptions(self) -> None: + """from_subnet_ids() creates specific subscriptions.""" + subnets = AttestationSubnets.from_subnet_ids([0, 5, 63]) + + assert subnets.is_subscribed(0) + assert subnets.is_subscribed(5) + assert subnets.is_subscribed(63) + assert not subnets.is_subscribed(1) + assert not subnets.is_subscribed(62) + assert subnets.subscription_count() == 3 + + def test_subscribed_subnets_list(self) -> None: + """subscribed_subnets() returns correct list.""" + subnets = AttestationSubnets.from_subnet_ids([10, 20, 30]) + result = subnets.subscribed_subnets() + + assert result == [10, 20, 30] + + def test_invalid_subnet_id_in_from_subnet_ids(self) -> None: + """from_subnet_ids() raises for invalid subnet IDs.""" + with pytest.raises(ValueError): + AttestationSubnets.from_subnet_ids([64]) + + with pytest.raises(ValueError): + AttestationSubnets.from_subnet_ids([-1]) + + def test_invalid_subnet_id_in_is_subscribed(self) -> None: + """is_subscribed() raises for invalid subnet IDs.""" + subnets = AttestationSubnets.none() + + with pytest.raises(ValueError): + subnets.is_subscribed(64) + + with pytest.raises(ValueError): + subnets.is_subscribed(-1) + + +class TestENR: + """Tests for ENR structure.""" + + def test_create_minimal_enr(self) -> None: + """ENR can be created with minimal valid data.""" + enr = ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={ + "id": b"v4", + "secp256k1": b"\x02" + b"\x00" * 32, # Compressed pubkey + }, + ) + assert enr.seq == Uint64(1) + assert enr.identity_scheme == "v4" + + def test_enr_ip4_property(self) -> None: + """ip4 property formats IPv4 address.""" + enr = ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={ + "id": b"v4", + "secp256k1": b"\x02" + b"\x00" * 32, + "ip": b"\xc0\xa8\x01\x01", # 192.168.1.1 + }, + ) + assert enr.ip4 == "192.168.1.1" + + def test_enr_tcp_port_property(self) -> None: + """tcp_port property extracts port number.""" + enr = ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={ + "id": b"v4", + "secp256k1": b"\x02" + b"\x00" * 32, + "tcp": (9000).to_bytes(2, "big"), + }, + ) + assert enr.tcp_port == 9000 + + def test_enr_multiaddr_construction(self) -> None: + """multiaddr() constructs valid multiaddress.""" + enr = ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={ + "id": b"v4", + "secp256k1": b"\x02" + b"\x00" * 32, + "ip": b"\xc0\xa8\x01\x01", + "tcp": (9000).to_bytes(2, "big"), + }, + ) + assert enr.multiaddr() == "/ip4/192.168.1.1/tcp/9000" + + def test_enr_has_key(self) -> None: + """has() correctly checks key presence.""" + enr = ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={ + "id": b"v4", + "secp256k1": b"\x02" + b"\x00" * 32, + }, + ) + assert enr.has(keys.ID) + assert enr.has(keys.SECP256K1) + assert not enr.has(keys.IP) + assert not enr.has(keys.ETH2) + + def test_enr_get_key(self) -> None: + """get() retrieves values by key.""" + enr = ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={ + "id": b"v4", + }, + ) + assert enr.get(keys.ID) == b"v4" + assert enr.get(keys.IP) is None + + def test_enr_is_valid_basic(self) -> None: + """is_valid() checks basic structure.""" + valid_enr = ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={ + "id": b"v4", + "secp256k1": b"\x02" + b"\x00" * 32, + }, + ) + assert valid_enr.is_valid() + + # Missing public key + invalid_enr = ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={ + "id": b"v4", + }, + ) + assert not invalid_enr.is_valid() + + def test_enr_compatibility(self) -> None: + """is_compatible_with() checks fork digest match.""" + eth2_bytes = b"\x12\x34\x56\x78" + b"\x02\x00\x00\x00" + b"\x00" * 8 + + enr1 = ENR( + signature=b"\x00" * 64, + seq=Uint64(1), + pairs={ + "id": b"v4", + "secp256k1": b"\x02" + b"\x00" * 32, + "eth2": eth2_bytes, + }, + ) + + enr2 = ENR( + signature=b"\x00" * 64, + seq=Uint64(2), + pairs={ + "id": b"v4", + "secp256k1": b"\x02" + b"\x00" * 32, + "eth2": eth2_bytes, + }, + ) + + assert enr1.is_compatible_with(enr2) + + def test_enr_string_representation(self) -> None: + """ENR has readable string representation.""" + enr = ENR( + signature=b"\x00" * 64, + seq=Uint64(42), + pairs={ + "id": b"v4", + "secp256k1": b"\x02" + b"\x00" * 32, + "ip": b"\xc0\xa8\x01\x01", + "tcp": (9000).to_bytes(2, "big"), + }, + ) + s = str(enr) + assert "seq=42" in s + assert "192.168.1.1" in s + assert "tcp=9000" in s