diff --git a/packages/testing/src/consensus_testing/test_fixtures/networking_codec.py b/packages/testing/src/consensus_testing/test_fixtures/networking_codec.py index cc2a0c852..7d10597aa 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/networking_codec.py +++ b/packages/testing/src/consensus_testing/test_fixtures/networking_codec.py @@ -14,7 +14,6 @@ ControlMessage, ControlPrune, Message, - PrunePeerInfo, SubOpts, ) from lean_spec.subspecs.networking.gossipsub.topic import GossipTopic, TopicKind @@ -341,14 +340,8 @@ def _make_enr(self) -> dict[str, Any]: output["ip4"] = enr.ip4 if enr.udp_port is not None: output["udpPort"] = int(enr.udp_port) - if enr.udp6_port is not None: - output["udp6Port"] = int(enr.udp6_port) - if enr.ip6: - output["ip6"] = enr.ip6 if enr.quic_port is not None: output["quicPort"] = int(enr.quic_port) - if enr.quic6_port is not None: - output["quic6Port"] = int(enr.quic6_port) if (ma := enr.multiaddr()) is not None: output["multiaddr"] = str(ma) if (eth2 := enr.eth2_data) is not None: @@ -359,8 +352,6 @@ def _make_enr(self) -> dict[str, Any]: } if (subnets := enr.attestation_subnets) is not None: output["attestationSubnets"] = [int(s) for s in subnets.subscribed_subnets()] - if (sync := enr.sync_committee_subnets) is not None: - output["syncCommitteeSubnets"] = [int(s) for s in sync.subscribed_subnets()] output["isAggregator"] = enr.is_aggregator output["signatureValid"] = enr.verify_signature() output["isValid"] = enr.is_valid() @@ -444,22 +435,12 @@ def _build_iwant(d: dict[str, Any]) -> ControlIWant: def _build_prune(d: dict[str, Any]) -> ControlPrune: """Build a ControlPrune from a dict.""" - peers = [_build_prune_peer(p) for p in d.get("peers", [])] return ControlPrune( topic_id=TopicId(d.get("topicId", "")), - peers=peers, backoff=d.get("backoff", 0), ) -def _build_prune_peer(p: dict[str, Any]) -> PrunePeerInfo: - """Build a PrunePeerInfo from a dict.""" - return PrunePeerInfo( - peer_id=_from_hex(p["peerId"]) if p.get("peerId") else b"", - signed_peer_record=(_from_hex(p["signedPeerRecord"]) if p.get("signedPeerRecord") else b""), - ) - - def _build_idontwant(d: dict[str, Any]) -> ControlIDontWant: """Build a ControlIDontWant from a dict.""" return ControlIDontWant(message_ids=[_from_hex(mid) for mid in d.get("messageIds", [])]) diff --git a/src/lean_spec/subspecs/networking/client/event_source/live.py b/src/lean_spec/subspecs/networking/client/event_source/live.py index 7ba11b57c..fc233ea13 100644 --- a/src/lean_spec/subspecs/networking/client/event_source/live.py +++ b/src/lean_spec/subspecs/networking/client/event_source/live.py @@ -36,11 +36,12 @@ Gossip uses raw Snappy block format. Req-resp uses Snappy framing with CRC32C. -GOSSIPSUB v1.1 REQUIREMENTS +GOSSIPSUB v1.2 REQUIREMENTS --------------------------- -The Ethereum consensus spec requires gossipsub v1.1 (protocol "/meshsub/1.1.0"). -Key v1.1 features used: +The node advertises gossipsub v1.2 (protocol "/meshsub/1.2.0"). +Key v1.2 features used: +- IDONTWANT control messages for bandwidth optimization. - Peer scoring: Misbehaving peers get lower scores. - Extended validators: Message validation before forwarding. - Flood publishing: High-priority messages bypass mesh constraints. @@ -48,7 +49,7 @@ References: - Ethereum P2P spec: https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/p2p-interface.md - - Gossipsub v1.1: https://github.com/libp2p/specs/blob/master/pubsub/gossipsub/gossipsub-v1.1.md + - Gossipsub v1.2: https://github.com/libp2p/specs/blob/master/pubsub/gossipsub/gossipsub-v1.2.md - SSZ spec: https://github.com/ethereum/consensus-specs/blob/dev/ssz/simple-serialize.md - Snappy format: https://github.com/google/snappy/blob/main/format_description.txt """ @@ -889,7 +890,7 @@ async def _negotiate_inbound_stream( # preserve it for later use (to avoid losing buffered data). try: wrapper = QuicStreamAdapter(stream) - gs_id = self._gossipsub_behavior._instance_id % 0xFFFF + gs_id = self._gossipsub_behavior._short_id logger.debug( "[GS %x] Accepting inbound stream %d from %s, negotiating protocol...", gs_id, @@ -958,7 +959,7 @@ async def _handle_gossipsub_inbound_stream( peer_id: Peer that opened the stream. conn: Connection the stream belongs to (used to open our outbound stream when needed). - protocol_id: Negotiated gossipsub protocol id (v1.1 or v1.2). + protocol_id: Negotiated gossipsub protocol id (v1.2). wrapper: Adapter holding any bytes already buffered during multistream-select. Reusing this wrapper preserves those bytes for the gossipsub behavior. @@ -973,9 +974,8 @@ async def _handle_gossipsub_inbound_stream( # - Outbound: we opened this to send our RPCs # - Inbound: they opened this to send us RPCs # - # We support both v1.1 and v1.2 - the difference is IDONTWANT - # messages which we can handle gracefully. - gs_id = self._gossipsub_behavior._instance_id % 0xFFFF + # We advertise gossipsub v1.2 only. + gs_id = self._gossipsub_behavior._short_id logger.debug( "[GS %x] Received inbound gossipsub stream (%s) from %s", gs_id, diff --git a/src/lean_spec/subspecs/networking/client/event_source/protocol.py b/src/lean_spec/subspecs/networking/client/event_source/protocol.py index 1f3cc7182..a44d64e4f 100644 --- a/src/lean_spec/subspecs/networking/client/event_source/protocol.py +++ b/src/lean_spec/subspecs/networking/client/event_source/protocol.py @@ -46,6 +46,6 @@ class GossipMessageError(Exception): Includes: -- GossipSub v1.1 and v1.2 +- GossipSub v1.2 - Request/response protocols (Status, BlocksByRoot) """ diff --git a/src/lean_spec/subspecs/networking/config.py b/src/lean_spec/subspecs/networking/config.py index 8e8e73104..884e6d5c7 100644 --- a/src/lean_spec/subspecs/networking/config.py +++ b/src/lean_spec/subspecs/networking/config.py @@ -12,13 +12,6 @@ MAX_PAYLOAD_SIZE: Final[int] = 10 * 1024 * 1024 """Maximum uncompressed payload size in bytes (10 MiB).""" -TTFB_TIMEOUT: Final[float] = 5.0 -"""Time-to-first-byte timeout. - -Maximum time to wait for the first byte of a response after sending a request. -If no data arrives within this window, the request is considered failed. -""" - RESP_TIMEOUT: Final[float] = 10.0 """Response timeout. @@ -38,22 +31,11 @@ Per Ethereum spec, prepended to the message hash when decompression succeeds. """ -GOSSIPSUB_PROTOCOL_ID_V11: Final = ProtocolId("/meshsub/1.1.0") -"""Gossipsub v1.1 protocol ID - peer scoring, extended validators. - -This is the minimum version required by the Ethereum consensus spec. -""" - GOSSIPSUB_PROTOCOL_ID_V12: Final = ProtocolId("/meshsub/1.2.0") """Gossipsub v1.2 protocol ID - IDONTWANT bandwidth optimization.""" GOSSIPSUB_DEFAULT_PROTOCOL_ID: Final = GOSSIPSUB_PROTOCOL_ID_V12 -""" -Default protocol ID per Ethereum consensus spec requirements. - -The Ethereum consensus P2P spec states: -"Clients MUST support the gossipsub v1 libp2p Protocol including the gossipsub v1.1 extension." -""" +"""Default gossipsub protocol ID advertised during stream negotiation.""" PRUNE_BACKOFF: Final[int] = 60 """Default PRUNE backoff duration in seconds. diff --git a/src/lean_spec/subspecs/networking/enr/__init__.py b/src/lean_spec/subspecs/networking/enr/__init__.py index 0dabd1b7a..6ec79a3eb 100644 --- a/src/lean_spec/subspecs/networking/enr/__init__.py +++ b/src/lean_spec/subspecs/networking/enr/__init__.py @@ -7,7 +7,7 @@ from . import keys from .enr import ENR -from .eth2 import FAR_FUTURE_EPOCH, AttestationSubnets, Eth2Data, SyncCommitteeSubnets +from .eth2 import FAR_FUTURE_EPOCH, AttestationSubnets, Eth2Data from .keys import EnrKey __all__ = [ @@ -16,6 +16,5 @@ "keys", "Eth2Data", "AttestationSubnets", - "SyncCommitteeSubnets", "FAR_FUTURE_EPOCH", ] diff --git a/src/lean_spec/subspecs/networking/enr/enr.py b/src/lean_spec/subspecs/networking/enr/enr.py index c27b78fdb..8c15c00b2 100644 --- a/src/lean_spec/subspecs/networking/enr/enr.py +++ b/src/lean_spec/subspecs/networking/enr/enr.py @@ -68,7 +68,7 @@ ) from . import keys -from .eth2 import AttestationSubnets, Eth2Data, SyncCommitteeSubnets +from .eth2 import AttestationSubnets, Eth2Data from .keys import EnrKey ENR_PREFIX: Final = "enr:" @@ -127,48 +127,26 @@ def ip4(self) -> str | None: 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) -> str | None: - """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 udp_port(self) -> Port | None: """UDP port for discovery.""" raw = self.get(keys.UDP) return Port(int.from_bytes(raw, "big")) if raw else None - @property - def udp6_port(self) -> Port | None: - """IPv6-specific UDP port.""" - raw = self.get(keys.UDP6) - return Port(int.from_bytes(raw, "big")) if raw else None - @property def quic_port(self) -> Port | None: """QUIC port for QUIC connections.""" raw = self.get(keys.QUIC) return Port(int.from_bytes(raw, "big")) if raw else None - @property - def quic6_port(self) -> Port | None: - """IPv6-specific QUIC port.""" - raw = self.get(keys.QUIC6) - return Port(int.from_bytes(raw, "big")) if raw else None - def multiaddr(self) -> Multiaddr | None: - """ - Construct QUIC multiaddress from endpoint info. + """Construct QUIC multiaddress from endpoint info. + Use QUIC port if available, otherwise UDP port. """ port = self.quic_port or self.udp_port if self.ip4 and port: return Multiaddr(f"/ip4/{self.ip4}/udp/{port}/quic-v1") - if self.ip6 and port: - return Multiaddr(f"/ip6/{self.ip6}/udp/{port}/quic-v1") return None @property @@ -189,14 +167,6 @@ def attestation_subnets(self) -> AttestationSubnets | None: attnets = self.get(keys.ATTNETS) return AttestationSubnets.decode_bytes(attnets) if attnets and len(attnets) == 8 else None - @property - def sync_committee_subnets(self) -> SyncCommitteeSubnets | None: - """Parse syncnets key (SSZ Bitvector[4]).""" - syncnets = self.get(keys.SYNCNETS) - if syncnets and len(syncnets) == 1: - return SyncCommitteeSubnets.decode_bytes(syncnets) - return None - @property def is_aggregator(self) -> bool: """Check if node advertises aggregator capability.""" diff --git a/src/lean_spec/subspecs/networking/enr/eth2.py b/src/lean_spec/subspecs/networking/enr/eth2.py index b0dca0120..7fd72c429 100644 --- a/src/lean_spec/subspecs/networking/enr/eth2.py +++ b/src/lean_spec/subspecs/networking/enr/eth2.py @@ -11,7 +11,6 @@ Subnet subscription keys (SSZ Bitvectors): - 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/master/specs/phase0/p2p-interface.md """ @@ -87,48 +86,3 @@ def subscribed_subnets(self) -> list[SubnetId]: 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)] * cls.LENGTH) - - @classmethod - def all(cls) -> "SyncCommitteeSubnets": - """Subscribe to all 4 subnets.""" - return cls(data=[Boolean(True)] * cls.LENGTH) - - @classmethod - def from_subnet_ids(cls, subnet_ids: list[int]) -> "SyncCommitteeSubnets": - """Subscribe to specific sync subnets.""" - bits = [Boolean(False)] * cls.LENGTH - for sid in subnet_ids: - if not 0 <= sid < cls.LENGTH: - raise ValueError(f"Sync subnet ID must be 0-3, got {sid}") - bits[sid] = Boolean(True) - return cls(data=bits) - - def is_subscribed(self, subnet_id: int) -> bool: - """Check if subscribed to a sync subnet.""" - if not 0 <= subnet_id < self.LENGTH: - raise ValueError(f"Sync subnet ID must be 0-3, got {subnet_id}") - return bool(self.data[subnet_id]) - - def subscribed_subnets(self) -> list[SubnetId]: - """List of subscribed sync subnet IDs.""" - return [SubnetId(i) for i in range(self.LENGTH) if self.data[i]] - - def subscription_count(self) -> int: - """Number of subscribed sync subnets.""" - return sum(1 for b in self.data if b) diff --git a/src/lean_spec/subspecs/networking/enr/keys.py b/src/lean_spec/subspecs/networking/enr/keys.py index 3b73acc3c..e18136efe 100644 --- a/src/lean_spec/subspecs/networking/enr/keys.py +++ b/src/lean_spec/subspecs/networking/enr/keys.py @@ -32,19 +32,10 @@ class EnrKey(str): UDP: Final = EnrKey("udp") """UDP port for discovery (big-endian integer).""" -IP6: Final = EnrKey("ip6") -"""IPv6 address (16 bytes).""" - -UDP6: Final = EnrKey("udp6") -"""IPv6-specific UDP port (big-endian integer).""" - # QUIC Keys QUIC: Final = EnrKey("quic") """QUIC port for QUIC connections (big-endian integer).""" -QUIC6: Final = EnrKey("quic6") -"""IPv6-specific QUIC port (big-endian integer).""" - # Ethereum Consensus Extensions ETH2: Final = EnrKey("eth2") """Ethereum consensus fork data (16 bytes).""" @@ -52,8 +43,5 @@ class EnrKey(str): ATTNETS: Final = EnrKey("attnets") """Attestation subnet subscriptions (8 bytes bitvector).""" -SYNCNETS: Final = EnrKey("syncnets") -"""Sync committee subnet subscriptions (1 byte bitvector).""" - IS_AGGREGATOR: Final = EnrKey("is_aggregator") """Aggregator capability flag (1 byte: 0x00 = false, 0x01 = true).""" diff --git a/src/lean_spec/subspecs/networking/gossipsub/message.py b/src/lean_spec/subspecs/networking/gossipsub/message.py index 75a3996a6..6efcb8a53 100644 --- a/src/lean_spec/subspecs/networking/gossipsub/message.py +++ b/src/lean_spec/subspecs/networking/gossipsub/message.py @@ -55,22 +55,12 @@ from __future__ import annotations import hashlib -from collections.abc import Callable from dataclasses import dataclass, field -from lean_spec.subspecs.networking.config import ( - MESSAGE_DOMAIN_INVALID_SNAPPY, - MESSAGE_DOMAIN_VALID_SNAPPY, -) +from lean_spec.subspecs.networking.config import MESSAGE_DOMAIN_INVALID_SNAPPY from .types import MessageId -type SnappyDecompressor = Callable[[bytes], bytes] -"""Callable that decompresses snappy-compressed data. - -Should raise an exception if decompression fails. -""" - @dataclass(slots=True) class GossipsubMessage: @@ -86,7 +76,7 @@ class GossipsubMessage: SHA256(domain + uint64_le(len(topic)) + topic + data)[:20] - Where `domain` depends on snappy decompression success. + Where `domain` is 0x01 for valid-snappy and 0x00 otherwise. """ topic: bytes @@ -99,14 +89,6 @@ class GossipsubMessage: depends on the topic (block, attestation, etc.). """ - snappy_decompress: SnappyDecompressor | None = field(default=None, repr=False) - """Optional snappy decompression function. - - If provided, decompression is attempted during ID computation - to determine the domain byte. Pass `snappy.decompress` from - the python-snappy library, or any compatible callable. - """ - _cached_id: MessageId | None = field( default=None, init=False, repr=False, compare=False, hash=False ) @@ -127,14 +109,13 @@ def id(self) -> MessageId: 20-byte message ID (Bytes20). """ if self._cached_id is None: - self._cached_id = self.compute_id(self.topic, self.raw_data, self.snappy_decompress) + self._cached_id = self.compute_id(self.topic, self.raw_data) return self._cached_id @staticmethod def compute_id( topic: bytes, data: bytes, - snappy_decompress: SnappyDecompressor | None = None, *, domain: bytes | None = None, ) -> MessageId: @@ -144,40 +125,22 @@ def compute_id( SHA256(domain + uint64_le(len(topic)) + topic + data)[:20] - Domain Selection - ---------------- - - - If `domain` is explicitly provided: - use it directly (data is assumed pre-processed by the caller) - - If `snappy_decompress` is provided and succeeds: - domain = 0x01, use decompressed data - - Otherwise: - domain = 0x00, use raw data - Args: topic: Topic string as bytes. - data: Message payload (potentially compressed). - snappy_decompress: Optional decompression function. - domain: Explicit domain bytes. When provided, data is used as-is. + data: Message payload. Callers that have already decompressed + the payload must pass the explicit `domain` so the hash + uses the correct domain separator. + domain: Explicit domain bytes. Defaults to the invalid-snappy + domain when omitted; callers handling decompression must + pass the valid-snappy domain explicitly. Returns: 20-byte message ID. """ - if domain is not None: - # Caller already determined the domain (e.g., after pre-decompression). - data_for_hash = data - elif snappy_decompress is not None: - try: - data_for_hash = snappy_decompress(data) - domain = MESSAGE_DOMAIN_VALID_SNAPPY - except Exception: - data_for_hash = data - domain = MESSAGE_DOMAIN_INVALID_SNAPPY - else: - data_for_hash = data + if domain is None: domain = MESSAGE_DOMAIN_INVALID_SNAPPY - preimage = bytes(domain) + len(topic).to_bytes(8, "little") + topic + data_for_hash + preimage = bytes(domain) + len(topic).to_bytes(8, "little") + topic + data return MessageId(hashlib.sha256(preimage).digest()[:20]) diff --git a/src/lean_spec/subspecs/networking/gossipsub/rpc.py b/src/lean_spec/subspecs/networking/gossipsub/rpc.py index 95b8c4288..75a5d8d81 100644 --- a/src/lean_spec/subspecs/networking/gossipsub/rpc.py +++ b/src/lean_spec/subspecs/networking/gossipsub/rpc.py @@ -352,50 +352,6 @@ def decode(cls, data: bytes) -> ControlGraft: return cls(topic_id=topic_id) -@dataclass(slots=True) -class PrunePeerInfo: - """Peer information for PRUNE peer exchange.""" - - peer_id: bytes = b"" - """Peer ID bytes.""" - - signed_peer_record: bytes = b"" - """Signed peer record (optional).""" - - def encode(self) -> bytes: - """Encode as protobuf.""" - result = bytearray() - if self.peer_id: - result.extend(encode_bytes(1, self.peer_id)) - if self.signed_peer_record: - result.extend(encode_bytes(2, self.signed_peer_record)) - return bytes(result) - - @classmethod - def decode(cls, data: bytes) -> PrunePeerInfo: - """Decode from protobuf.""" - peer_id = b"" - signed_peer_record = b"" - pos = 0 - - while pos < len(data): - field_num, wire_type, pos = decode_tag(data, pos) - - if wire_type == WIRE_TYPE_LENGTH_DELIMITED: - length, pos = _decode_length_at(data, pos) - field_data = data[pos : pos + length] - pos += length - - if field_num == 1: - peer_id = field_data - elif field_num == 2: - signed_peer_record = field_data - else: - pos = _skip_field(data, pos, wire_type) - - return cls(peer_id=peer_id, signed_peer_record=signed_peer_record) - - @dataclass(slots=True) class ControlPrune: """PRUNE control message - notification of mesh removal.""" @@ -403,9 +359,6 @@ class ControlPrune: topic_id: TopicId = TopicId("") """Topic being pruned from.""" - peers: list[PrunePeerInfo] = field(default_factory=list) - """Peer exchange - alternative peers for the topic (v1.1).""" - backoff: int = 0 """Backoff duration in seconds before re-grafting (v1.1).""" @@ -414,8 +367,6 @@ def encode(self) -> bytes: result = bytearray() if self.topic_id: result.extend(encode_string(1, self.topic_id)) - for peer in self.peers: - result.extend(encode_length_delimited(2, peer.encode())) if self.backoff > 0: result.extend(encode_uint64(3, self.backoff)) return bytes(result) @@ -424,7 +375,6 @@ def encode(self) -> bytes: def decode(cls, data: bytes) -> ControlPrune: """Decode from protobuf.""" topic_id = TopicId("") - peers: list[PrunePeerInfo] = [] backoff = 0 pos = 0 @@ -435,16 +385,12 @@ def decode(cls, data: bytes) -> ControlPrune: length, pos = _decode_length_at(data, pos) topic_id = TopicId(data[pos : pos + length].decode("utf-8")) pos += length - elif field_num == 2 and wire_type == WIRE_TYPE_LENGTH_DELIMITED: - length, pos = _decode_length_at(data, pos) - peers.append(PrunePeerInfo.decode(data[pos : pos + length])) - pos += length elif field_num == 3 and wire_type == WIRE_TYPE_VARINT: backoff, pos = _decode_varint_at(data, pos) else: pos = _skip_field(data, pos, wire_type) - return cls(topic_id=topic_id, peers=peers, backoff=backoff) + return cls(topic_id=topic_id, backoff=backoff) @dataclass(slots=True) diff --git a/src/lean_spec/subspecs/networking/transport/__init__.py b/src/lean_spec/subspecs/networking/transport/__init__.py index 2c3131f82..e51712d3d 100644 --- a/src/lean_spec/subspecs/networking/transport/__init__.py +++ b/src/lean_spec/subspecs/networking/transport/__init__.py @@ -11,7 +11,7 @@ Components: - quic/: QUIC transport with libp2p-tls authentication and protocol negotiation - - identity/: secp256k1 keypairs and identity proofs + - identity/: secp256k1 keypairs QUIC provides encryption and multiplexing natively, eliminating the need for separate Noise and yamux layers. This results @@ -22,13 +22,7 @@ - libp2p/specs quic, tls, multistream-select """ -from .identity import ( - NOISE_IDENTITY_PREFIX, - IdentityKeypair, - Secp256k1PublicKey, - create_identity_proof, - verify_identity_proof, -) +from .identity import IdentityKeypair, Secp256k1PublicKey from .peer_id import Base58, KeyType, Multihash, MultihashCode, PeerId, PublicKeyProto from .quic import ( NegotiationError, @@ -48,9 +42,6 @@ # Identity (secp256k1 keypair) "IdentityKeypair", "Secp256k1PublicKey", - "NOISE_IDENTITY_PREFIX", - "create_identity_proof", - "verify_identity_proof", # PeerId (peer_id module) "PeerId", "PublicKeyProto", diff --git a/src/lean_spec/subspecs/networking/transport/identity/__init__.py b/src/lean_spec/subspecs/networking/transport/identity/__init__.py index 47854cb36..14ff77b22 100644 --- a/src/lean_spec/subspecs/networking/transport/identity/__init__.py +++ b/src/lean_spec/subspecs/networking/transport/identity/__init__.py @@ -1,28 +1,12 @@ """ libp2p identity module. -Provides secp256k1 keypair management for peer identity and identity -proof signatures for the Noise handshake. - -The identity key is separate from the Noise key: -- Identity key (secp256k1): Used to derive PeerId, sign identity proofs -- Noise key (X25519): Used for encrypted communication - -This separation follows the libp2p-noise specification and matches -the approach used by ream and zeam. +Provides secp256k1 keypair management for peer identity (PeerId derivation). """ from .keypair import IdentityKeypair, Secp256k1PublicKey -from .signature import ( - NOISE_IDENTITY_PREFIX, - create_identity_proof, - verify_identity_proof, -) __all__ = [ "IdentityKeypair", "Secp256k1PublicKey", - "NOISE_IDENTITY_PREFIX", - "create_identity_proof", - "verify_identity_proof", ] diff --git a/src/lean_spec/subspecs/networking/transport/identity/signature.py b/src/lean_spec/subspecs/networking/transport/identity/signature.py deleted file mode 100644 index a430c5eeb..000000000 --- a/src/lean_spec/subspecs/networking/transport/identity/signature.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -Identity proof for libp2p peer authentication. - -During the QUIC TLS handshake, peers must prove they own their claimed -libp2p identity key by signing the TLS public key. - -The signature format follows the libp2p specification: - message = "noise-libp2p-static-key:" || public_key - signature = ECDSA-SHA256(identity_private_key, message) - -References: - - https://github.com/libp2p/specs/blob/master/noise/README.md -""" - -from __future__ import annotations - -from typing import Final - -from lean_spec.types import Bytes32 - -from .keypair import IdentityKeypair, Secp256k1PublicKey - -__all__ = [ - "NOISE_IDENTITY_PREFIX", - "create_identity_proof", - "verify_identity_proof", -] - - -NOISE_IDENTITY_PREFIX: Final[bytes] = b"noise-libp2p-static-key:" -"""Prefix for the identity proof message per libp2p-noise spec.""" - - -def create_identity_proof( - identity_key: IdentityKeypair, - public_key: Bytes32, -) -> bytes: - """ - Create identity proof signature for peer authentication. - - Proves that the owner of the identity key (secp256k1) also controls - the TLS static key. This binding prevents man-in-the-middle attacks. - - Args: - identity_key: The secp256k1 identity keypair. - public_key: The 32-byte TLS public key. - - Returns: - DER-encoded ECDSA signature. - """ - message = NOISE_IDENTITY_PREFIX + public_key - return identity_key.sign(message) - - -def verify_identity_proof( - identity_public_key: Secp256k1PublicKey, - public_key: Bytes32, - signature: bytes, -) -> bool: - """ - Verify identity proof signature. - - Called during QUIC TLS handshake to verify the remote peer's identity claim. - - Args: - identity_public_key: The secp256k1 public key claiming identity. - public_key: 32-byte TLS public key. - signature: DER-encoded ECDSA signature. - - Returns: - True if the signature is valid, False otherwise. - """ - message = NOISE_IDENTITY_PREFIX + public_key - return identity_public_key.verify(message, signature) diff --git a/src/lean_spec/subspecs/networking/transport/quic/connection.py b/src/lean_spec/subspecs/networking/transport/quic/connection.py index 7f53a02de..51573f5ba 100644 --- a/src/lean_spec/subspecs/networking/transport/quic/connection.py +++ b/src/lean_spec/subspecs/networking/transport/quic/connection.py @@ -291,7 +291,7 @@ def parse_multiaddr(multiaddr: str) -> tuple[str, int, str | None, PeerId | None i = 0 while i < len(parts): - if parts[i] in ("ip4", "ip6") and i + 1 < len(parts): + if parts[i] == "ip4" and i + 1 < len(parts): host = parts[i + 1] i += 2 elif parts[i] == "udp" and i + 1 < len(parts): diff --git a/src/lean_spec/subspecs/networking/transport/quic/stream_adapter.py b/src/lean_spec/subspecs/networking/transport/quic/stream_adapter.py index 3d8e3fb00..a6d64af45 100644 --- a/src/lean_spec/subspecs/networking/transport/quic/stream_adapter.py +++ b/src/lean_spec/subspecs/networking/transport/quic/stream_adapter.py @@ -146,44 +146,6 @@ async def finish_write(self) -> None: self._write_buffer = b"" await self._stream.finish_write() - async def negotiate_client(self, protocols: list[ProtocolId]) -> ProtocolId: - """Client-side protocol negotiation. - - Proposes protocols in order until one is accepted. - - Args: - protocols: Protocols to propose, in preference order. - - Returns: - The accepted protocol ID. - - Raises: - NegotiationError: If no protocol is accepted or protocol error. - """ - if not protocols: - raise NegotiationError("No protocols to negotiate") - - # Exchange multistream headers. - await self._write_negotiation_message(MULTISTREAM_PROTOCOL_ID) - header = await self._read_negotiation_message() - - if header != MULTISTREAM_PROTOCOL_ID: - raise NegotiationError(f"Invalid multistream header: {header!r}") - - # Try each protocol in order. - for protocol in protocols: - await self._write_negotiation_message(protocol) - response = await self._read_negotiation_message() - - if response == protocol: - return protocol - elif response == NA: - continue - else: - raise NegotiationError(f"Unexpected response: {response!r}") - - raise NegotiationError(f"No protocols accepted from: {protocols}") - async def negotiate_server( self, supported: set[ProtocolId], diff --git a/tests/consensus/lstar/networking/test_enr_and_peer_id.py b/tests/consensus/lstar/networking/test_enr_and_peer_id.py index f9294a3f5..b3bc3e95f 100644 --- a/tests/consensus/lstar/networking/test_enr_and_peer_id.py +++ b/tests/consensus/lstar/networking/test_enr_and_peer_id.py @@ -34,7 +34,7 @@ "gZu_w8M-3NHdkIC7dKuyKgxbxV8mr0cBgmlkgnY0gmlwhAoAAAGJc2VjcDI1NmsxoQ" "PKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8" ) -"""ENR with ip4 + udp but no eth2, attnets, or syncnets keys.""" +"""ENR with ip4 + udp but no eth2 or attnets keys.""" ENR_ETH2_FAR_FUTURE = ( "enr:-Iu4QH9jczrZFmQYFzYOnzvoHr0x_oqo6uiVwYyfW4JqmzieRFUycscJpHoQ" @@ -57,21 +57,6 @@ ) """ENR with attnets bitfield all ones (all 64 subnets subscribed).""" -ENR_SYNCNETS_ONLY = ( - "enr:-H-4QE_YuRp32SAUCRWpLtCZnfsZWnD8SrO0SJE8T3x6XdYJvBKI9eW79Vy9" - "AEIjRGL8jLSgCV2BpEJ4-5NQSksSvyEBgmlkgnY0iXNlY3AyNTZrMaEDymNMrg1JrL" - "QB2KTGtv6MVbcNEVv0AHacwUAPMljNMTiIc3luY25ldHMK" -) -"""ENR with syncnets key (subnets 1, 3) but no eth2 or attnets.""" - -ENR_IPV6_ONLY = ( - "enr:-JK4QGjix5Zy3h0NXP7WkXobaBJtkv_bAMf5pkq17EIThF7rSvyRoBA_dQ93_u" - "-icZRoK_-vdEy2RM1AKuAKArf2z3UBgmlkgnY0g2lwNpAgAQ24AAAAAAAAAAAAAAAB" - "iXNlY3AyNTZrMaEDymNMrg1JrLQB2KTGtv6MVbcNEVv0AHacwUAPMljNMTiEdWRwNo" - "IjKQ" -) -"""ENR with IPv6 address (2001:db8::1) and udp6 port, no IPv4.""" - ENR_HIGH_SEQ = ( "enr:-Hm4QIijml7AMdlciWFY1S7qh7egBawTx_IqvFhHI4xQh6jdDEJv9Slx_lVG" "PznU65wh0BzwxMGkbEpzuzO2K7Z_xfSE_____4JpZIJ2NIlzZWNwMjU2azGhA8pjTK" @@ -89,7 +74,7 @@ def test_enr_official_eip778(networking_codec: NetworkingCodecTestFiller) -> Non def test_enr_with_extensions(networking_codec: NetworkingCodecTestFiller) -> None: - """ENR with all extension keys: eth2, subnets, IPv6, QUIC, udp6, quic6, is_aggregator.""" + """ENR with extension keys: eth2, attnets, QUIC, is_aggregator.""" networking_codec(codec_name="enr", input={"enrString": ENR_WITH_EXTENSIONS}) @@ -99,7 +84,7 @@ def test_enr_minimal_seq_zero(networking_codec: NetworkingCodecTestFiller) -> No def test_enr_no_eth2_field(networking_codec: NetworkingCodecTestFiller) -> None: - """ENR with ip4 + udp but no eth2, attnets, or syncnets. Verifies absent optional fields.""" + """ENR with ip4 + udp but no eth2 or attnets. Verifies absent optional fields.""" networking_codec(codec_name="enr", input={"enrString": ENR_NO_ETH2}) @@ -118,16 +103,6 @@ def test_enr_attnets_all_ones(networking_codec: NetworkingCodecTestFiller) -> No networking_codec(codec_name="enr", input={"enrString": ENR_ATTNETS_ALL_ONES}) -def test_enr_syncnets_only(networking_codec: NetworkingCodecTestFiller) -> None: - """ENR with syncnets (subnets 1, 3) but no eth2 or attnets. Verifies independent parsing.""" - networking_codec(codec_name="enr", input={"enrString": ENR_SYNCNETS_ONLY}) - - -def test_enr_ipv6_only(networking_codec: NetworkingCodecTestFiller) -> None: - """ENR with IPv6 address (2001:db8::1) and udp6 port, no IPv4.""" - networking_codec(codec_name="enr", input={"enrString": ENR_IPV6_ONLY}) - - def test_enr_high_seq(networking_codec: NetworkingCodecTestFiller) -> None: """ENR with high sequence number (2^32 - 1). Verifies large seq parsing.""" networking_codec(codec_name="enr", input={"enrString": ENR_HIGH_SEQ}) diff --git a/tests/consensus/lstar/ssz/test_basic_types.py b/tests/consensus/lstar/ssz/test_basic_types.py index 8a9c2be5c..72fdde29f 100644 --- a/tests/consensus/lstar/ssz/test_basic_types.py +++ b/tests/consensus/lstar/ssz/test_basic_types.py @@ -6,7 +6,7 @@ from consensus_testing import SSZTestFiller from lean_spec.subspecs.koalabear import Fp, P -from lean_spec.subspecs.networking.enr.eth2 import AttestationSubnets, SyncCommitteeSubnets +from lean_spec.subspecs.networking.enr.eth2 import AttestationSubnets from lean_spec.types import ( BaseBitlist, BaseBitvector, @@ -503,21 +503,3 @@ def test_attestation_subnets_partial(ssz: SSZTestFiller) -> None: type_name="AttestationSubnets", value=AttestationSubnets.from_subnet_ids([0, 7, 15, 31, 63]), ) - - -def test_sync_committee_subnets_none(ssz: SSZTestFiller) -> None: - """Sync committee subnets with no subscriptions (all 4 bits clear).""" - ssz(type_name="SyncCommitteeSubnets", value=SyncCommitteeSubnets.none()) - - -def test_sync_committee_subnets_all(ssz: SSZTestFiller) -> None: - """Sync committee subnets with all 4 subscriptions active.""" - ssz(type_name="SyncCommitteeSubnets", value=SyncCommitteeSubnets.all()) - - -def test_sync_committee_subnets_partial(ssz: SSZTestFiller) -> None: - """Sync committee subnets with 2 of 4 selected (boundary IDs 0 and 3).""" - ssz( - type_name="SyncCommitteeSubnets", - value=SyncCommitteeSubnets.from_subnet_ids([0, 3]), - ) diff --git a/tests/interop/helpers/node_runner.py b/tests/interop/helpers/node_runner.py index 05dccafa6..c371fdb78 100644 --- a/tests/interop/helpers/node_runner.py +++ b/tests/interop/helpers/node_runner.py @@ -396,7 +396,7 @@ async def start_node( self.nodes.append(test_node) # Log node startup with gossipsub instance ID for debugging. - gs_id = event_source._gossipsub_behavior._instance_id % 0xFFFF + gs_id = event_source._gossipsub_behavior._short_id logger.info( "Started node %d on %s (validators: %s, services=%s, GS=%x)", node_index, diff --git a/tests/lean_spec/subspecs/networking/client/test_event_source.py b/tests/lean_spec/subspecs/networking/client/test_event_source.py index 9fd822232..7988ac6fb 100644 --- a/tests/lean_spec/subspecs/networking/client/test_event_source.py +++ b/tests/lean_spec/subspecs/networking/client/test_event_source.py @@ -203,13 +203,13 @@ class TestSupportedProtocols: """ Verify the set of protocol IDs advertised during connection setup. - An Ethereum consensus node must support gossipsub v1.1, gossipsub v1.2 - (for IDONTWANT bandwidth optimization), and all req/resp protocol IDs. + The node advertises gossipsub v1.2 (for IDONTWANT bandwidth optimization) + and all req/resp protocol IDs. The set must be immutable to prevent accidental mutation at runtime. """ - def test_contains_gossipsub_v11(self) -> None: - """Includes gossipsub v1.1 as required by Ethereum consensus spec.""" + def test_contains_gossipsub_default(self) -> None: + """Includes the default gossipsub protocol ID.""" assert GOSSIPSUB_DEFAULT_PROTOCOL_ID in SUPPORTED_PROTOCOLS def test_contains_gossipsub_v12(self) -> None: diff --git a/tests/lean_spec/subspecs/networking/enr/test_enr.py b/tests/lean_spec/subspecs/networking/enr/test_enr.py index 9eed42e55..ea019e599 100644 --- a/tests/lean_spec/subspecs/networking/enr/test_enr.py +++ b/tests/lean_spec/subspecs/networking/enr/test_enr.py @@ -105,11 +105,6 @@ def test_official_enr_is_valid(self) -> None: enr = ENR.from_string(OFFICIAL_ENR_STRING) assert enr.is_valid() - def test_official_enr_no_ipv6(self) -> None: - """Official ENR does not have IPv6 address.""" - enr = ENR.from_string(OFFICIAL_ENR_STRING) - assert enr.ip6 is None - def test_official_enr_has_quic_multiaddr(self) -> None: """Official ENR has QUIC multiaddr (has UDP port).""" enr = ENR.from_string(OFFICIAL_ENR_STRING) @@ -344,35 +339,6 @@ def test_ip4_returns_none_for_wrong_length(self) -> None: ) assert enr.ip4 is None - def test_ip6_formats_address_correctly(self) -> None: - """ip6 property formats IPv6 address as colon-separated hex.""" - # ::1 (loopback) - ipv6_bytes = b"\x00" * 15 + b"\x01" - enr = ENR( - signature=Bytes64(b"\x00" * 64), - seq=SeqNumber(1), - pairs={keys.ID: b"v4", keys.IP6: ipv6_bytes}, - ) - assert enr.ip6 == "0000:0000:0000:0000:0000:0000:0000:0001" - - def test_ip6_returns_none_when_missing(self) -> None: - """ip6 returns None when 'ip6' key is absent.""" - enr = ENR( - signature=Bytes64(b"\x00" * 64), - seq=SeqNumber(1), - pairs={keys.ID: b"v4"}, - ) - assert enr.ip6 is None - - def test_ip6_returns_none_for_wrong_length(self) -> None: - """ip6 returns None when IP bytes are not 16 bytes.""" - enr = ENR( - signature=Bytes64(b"\x00" * 64), - seq=SeqNumber(1), - pairs={keys.ID: b"v4", keys.IP6: b"\x00" * 8}, # Only 8 bytes - ) - assert enr.ip6 is None - def test_udp_port_extracts_correctly(self) -> None: """udp_port extracts port number from big-endian bytes.""" enr = ENR( @@ -492,20 +458,6 @@ def test_multiaddr_with_ipv4_and_udp(self) -> None: ) assert enr.multiaddr() == "/ip4/192.168.1.1/udp/9000/quic-v1" - def test_multiaddr_with_ipv6_and_udp(self) -> None: - """multiaddr() generates QUIC format with IPv6 and UDP.""" - ipv6_bytes = b"\x00" * 15 + b"\x01" # ::1 - enr = ENR( - signature=Bytes64(b"\x00" * 64), - seq=SeqNumber(1), - pairs={ - keys.ID: b"v4", - keys.IP6: ipv6_bytes, - keys.UDP: (9000).to_bytes(2, "big"), - }, - ) - assert enr.multiaddr() == "/ip6/0000:0000:0000:0000:0000:0000:0000:0001/udp/9000/quic-v1" - def test_multiaddr_returns_none_without_udp(self) -> None: """multiaddr() returns None when UDP port is absent.""" enr = ENR( @@ -527,20 +479,6 @@ def test_multiaddr_returns_none_without_ip(self) -> None: ) assert enr.multiaddr() is None - def test_multiaddr_prefers_ipv4_over_ipv6(self) -> None: - """multiaddr() uses IPv4 when both IPv4 and IPv6 are present.""" - enr = ENR( - signature=Bytes64(b"\x00" * 64), - seq=SeqNumber(1), - pairs={ - keys.ID: b"v4", - keys.IP: b"\xc0\xa8\x01\x01", # 192.168.1.1 - keys.IP6: b"\x00" * 15 + b"\x01", # ::1 - keys.UDP: (9000).to_bytes(2, "big"), - }, - ) - assert enr.multiaddr() == "/ip4/192.168.1.1/udp/9000/quic-v1" - class TestStringRepresentation: """Tests for ENR string representation.""" @@ -632,35 +570,6 @@ def test_enr_with_only_required_fields(self) -> None: assert enr.ip4 is None assert enr.udp_port is None - def test_enr_with_ipv6_only(self) -> None: - """ENR with IPv6 but no IPv4 parses correctly.""" - ipv6_bytes = bytes.fromhex("20010db8000000000000000000000001") # 2001:db8::1 - rlp_data = encode_rlp( - [ - b"\x00" * 64, - b"\x01", - b"id", - b"v4", - b"ip6", - ipv6_bytes, - b"secp256k1", - b"\x02" + b"\x00" * 32, - b"udp", - (9000).to_bytes(2, "big"), - ] - ) - b64_content = base64.urlsafe_b64encode(rlp_data).decode("utf-8").rstrip("=") - - enr = ENR.from_string(f"enr:{b64_content}") - assert enr.ip4 is None - assert enr.ip6 is not None - assert enr.udp_port == Port(9000) - # multiaddr should use IPv6 with QUIC - multiaddr = enr.multiaddr() - assert multiaddr is not None - assert "/ip6/" in multiaddr - assert "/quic-v1" in multiaddr - def test_enr_with_udp_port(self) -> None: """ENR with UDP port generates QUIC multiaddr correctly.""" rlp_data = encode_rlp( diff --git a/tests/lean_spec/subspecs/networking/enr/test_eth2.py b/tests/lean_spec/subspecs/networking/enr/test_eth2.py index 0b4896d8a..171644b92 100644 --- a/tests/lean_spec/subspecs/networking/enr/test_eth2.py +++ b/tests/lean_spec/subspecs/networking/enr/test_eth2.py @@ -1,4 +1,4 @@ -"""Tests for Ethereum 2.0 ENR types (Eth2Data, AttestationSubnets, SyncCommitteeSubnets).""" +"""Tests for Ethereum 2.0 ENR types (Eth2Data, AttestationSubnets).""" import pytest from pydantic import ValidationError @@ -7,7 +7,6 @@ from lean_spec.subspecs.networking.enr.eth2 import ( FAR_FUTURE_EPOCH, AttestationSubnets, - SyncCommitteeSubnets, ) from lean_spec.subspecs.networking.types import ForkDigest, Version from lean_spec.types import SubnetId, Uint64 @@ -124,97 +123,3 @@ def test_decode_bytes_roundtrip(self) -> None: def test_length_constant(self) -> None: """LENGTH constant is 64.""" assert AttestationSubnets.LENGTH == 64 - - -class TestSyncCommitteeSubnets: - """Tests for SyncCommitteeSubnets bitvector.""" - - def test_none_creates_empty_subscriptions(self) -> None: - """none() creates empty subscriptions.""" - subnets = SyncCommitteeSubnets.none() - for i in range(4): - assert not subnets.is_subscribed(i) - - def test_all_creates_full_subscriptions(self) -> None: - """all() creates full subscriptions.""" - subnets = SyncCommitteeSubnets.all() - for i in range(4): - assert subnets.is_subscribed(i) - - def test_is_subscribed_with_valid_ids(self) -> None: - """is_subscribed() works for valid subnet IDs 0-3.""" - subnets = SyncCommitteeSubnets.all() - assert subnets.is_subscribed(0) - assert subnets.is_subscribed(1) - assert subnets.is_subscribed(2) - assert subnets.is_subscribed(3) - - def test_is_subscribed_raises_for_invalid_high_id(self) -> None: - """is_subscribed() raises for subnet ID >= 4.""" - subnets = SyncCommitteeSubnets.none() - with pytest.raises(ValueError, match="must be 0-3"): - subnets.is_subscribed(4) - - def test_is_subscribed_raises_for_negative_id(self) -> None: - """is_subscribed() raises for negative subnet ID.""" - subnets = SyncCommitteeSubnets.none() - with pytest.raises(ValueError, match="must be 0-3"): - subnets.is_subscribed(-1) - - def test_from_subnet_ids_specific(self) -> None: - """from_subnet_ids() creates specific subscriptions.""" - subnets = SyncCommitteeSubnets.from_subnet_ids([0, 2]) - assert subnets.is_subscribed(0) - assert not subnets.is_subscribed(1) - assert subnets.is_subscribed(2) - assert not subnets.is_subscribed(3) - - def test_from_subnet_ids_empty_list(self) -> None: - """from_subnet_ids with empty list creates no subscriptions.""" - subnets = SyncCommitteeSubnets.from_subnet_ids([]) - assert subnets.subscription_count() == 0 - - def test_from_subnet_ids_with_duplicates(self) -> None: - """from_subnet_ids handles duplicates correctly.""" - subnets = SyncCommitteeSubnets.from_subnet_ids([1, 1, 1, 3]) - assert subnets.subscription_count() == 2 - assert subnets.subscribed_subnets() == [SubnetId(1), SubnetId(3)] - - def test_from_subnet_ids_invalid(self) -> None: - """from_subnet_ids() raises for invalid subnet IDs.""" - with pytest.raises(ValueError, match="must be 0-3"): - SyncCommitteeSubnets.from_subnet_ids([4]) - - with pytest.raises(ValueError, match="must be 0-3"): - SyncCommitteeSubnets.from_subnet_ids([-1]) - - def test_subscribed_subnets(self) -> None: - """subscribed_subnets() returns correct list.""" - subnets = SyncCommitteeSubnets.from_subnet_ids([1, 3]) - assert subnets.subscribed_subnets() == [SubnetId(1), SubnetId(3)] - - def test_subscription_count(self) -> None: - """subscription_count() returns correct count.""" - subnets = SyncCommitteeSubnets.from_subnet_ids([0, 2, 3]) - assert subnets.subscription_count() == 3 - - def test_encode_bytes_empty(self) -> None: - """Empty subscriptions serialize to 1 zero byte.""" - subnets = SyncCommitteeSubnets.none() - assert subnets.encode_bytes() == b"\x00" - - def test_encode_bytes_all(self) -> None: - """All subscriptions serialize to 0x0f (lower 4 bits set).""" - subnets = SyncCommitteeSubnets.all() - assert subnets.encode_bytes() == b"\x0f" - - def test_decode_bytes_roundtrip(self) -> None: - """Encode then decode produces equivalent result.""" - original = SyncCommitteeSubnets.from_subnet_ids([0, 2]) - encoded = original.encode_bytes() - decoded = SyncCommitteeSubnets.decode_bytes(encoded) - assert decoded.subscribed_subnets() == original.subscribed_subnets() - - def test_length_constant(self) -> None: - """LENGTH constant is 4.""" - assert SyncCommitteeSubnets.LENGTH == 4 diff --git a/tests/lean_spec/subspecs/networking/enr/test_keys.py b/tests/lean_spec/subspecs/networking/enr/test_keys.py index 7da5f7cc3..85ccb175d 100644 --- a/tests/lean_spec/subspecs/networking/enr/test_keys.py +++ b/tests/lean_spec/subspecs/networking/enr/test_keys.py @@ -14,12 +14,9 @@ def test_identity_keys(self) -> None: def test_network_keys(self) -> None: """Network keys have correct values.""" assert keys.IP == "ip" - assert keys.IP6 == "ip6" assert keys.UDP == "udp" - assert keys.UDP6 == "udp6" def test_ethereum_keys(self) -> None: """Ethereum-specific keys have correct values.""" assert keys.ETH2 == "eth2" assert keys.ATTNETS == "attnets" - assert keys.SYNCNETS == "syncnets" diff --git a/tests/lean_spec/subspecs/networking/gossipsub/test_cache_edge_cases.py b/tests/lean_spec/subspecs/networking/gossipsub/test_cache_edge_cases.py index cd345e692..f3ecf2ea7 100644 --- a/tests/lean_spec/subspecs/networking/gossipsub/test_cache_edge_cases.py +++ b/tests/lean_spec/subspecs/networking/gossipsub/test_cache_edge_cases.py @@ -232,45 +232,30 @@ def test_id_is_20_bytes(self) -> None: assert isinstance(msg.id, MessageId) def test_id_is_cached(self) -> None: - """The ID is computed once and reused on subsequent accesses.""" - decompress_calls = 0 - - def counting_decompress(data: bytes) -> bytes: - nonlocal decompress_calls - decompress_calls += 1 - return b"decompressed" - - msg = GossipsubMessage(topic=b"t", raw_data=b"d", snappy_decompress=counting_decompress) + """The ID is computed once and the same object is returned on subsequent accesses.""" + msg = GossipsubMessage(topic=b"t", raw_data=b"d") first_id = msg.id second_id = msg.id - assert decompress_calls == 1 assert first_id is second_id - def test_compute_id_with_snappy(self) -> None: - """compute_id uses decompressed data when snappy succeeds.""" - raw = b"compressed" - decompressed = b"decompressed" - - id_with_snappy = GossipsubMessage.compute_id( - b"t", raw, snappy_decompress=lambda _: decompressed + def test_compute_id_default_domain_invalid_snappy(self) -> None: + """compute_id uses the invalid-snappy domain when domain is omitted.""" + from lean_spec.subspecs.networking.config import ( + MESSAGE_DOMAIN_INVALID_SNAPPY, + MESSAGE_DOMAIN_VALID_SNAPPY, ) - id_without = GossipsubMessage.compute_id(b"t", raw) - - # Different domain bytes and data -> different IDs. - assert id_with_snappy != id_without - def test_compute_id_with_failed_snappy(self) -> None: - """compute_id falls back to raw data when snappy fails.""" - - def bad_decompress(_: bytes) -> bytes: - raise RuntimeError("bad snappy") - - id_failed = GossipsubMessage.compute_id(b"t", b"data", snappy_decompress=bad_decompress) - id_none = GossipsubMessage.compute_id(b"t", b"data", snappy_decompress=None) + id_default = GossipsubMessage.compute_id(b"t", b"data") + id_explicit_invalid = GossipsubMessage.compute_id( + b"t", b"data", domain=MESSAGE_DOMAIN_INVALID_SNAPPY + ) + id_explicit_valid = GossipsubMessage.compute_id( + b"t", b"data", domain=MESSAGE_DOMAIN_VALID_SNAPPY + ) - # Both use INVALID_SNAPPY domain + raw data -> same ID. - assert id_failed == id_none + assert id_default == id_explicit_invalid + assert id_default != id_explicit_valid class TestGossipsubMessageHash: diff --git a/tests/lean_spec/subspecs/networking/gossipsub/test_rpc_edge_cases.py b/tests/lean_spec/subspecs/networking/gossipsub/test_rpc_edge_cases.py index 2a12992dc..9bdc714e3 100644 --- a/tests/lean_spec/subspecs/networking/gossipsub/test_rpc_edge_cases.py +++ b/tests/lean_spec/subspecs/networking/gossipsub/test_rpc_edge_cases.py @@ -17,7 +17,6 @@ ControlPrune, Message, ProtobufDecodeError, - PrunePeerInfo, SubOpts, _skip_field, encode_bytes, @@ -26,47 +25,6 @@ from lean_spec.subspecs.networking.gossipsub.types import TopicId -class TestPrunePeerInfoRoundtrip: - """Tests for PrunePeerInfo protobuf encoding/decoding.""" - - def test_peer_info_with_both_fields(self) -> None: - """PrunePeerInfo roundtrips with both peer_id and signed_peer_record.""" - info = PrunePeerInfo(peer_id=b"peer123", signed_peer_record=b"record456") - assert PrunePeerInfo.decode(info.encode()) == info - - def test_peer_info_peer_id_only(self) -> None: - """PrunePeerInfo roundtrips with only peer_id.""" - info = PrunePeerInfo(peer_id=b"peerOnly") - assert PrunePeerInfo.decode(info.encode()) == info - - def test_peer_info_empty(self) -> None: - """Empty PrunePeerInfo produces empty encoding.""" - info = PrunePeerInfo() - assert info.encode() == b"" - assert PrunePeerInfo.decode(b"") == PrunePeerInfo() - - -class TestPruneWithPeerExchange: - """Tests for ControlPrune with the peers field.""" - - def test_prune_with_peers(self) -> None: - """ControlPrune roundtrips with peer exchange info.""" - prune = ControlPrune( - topic_id=TopicId("/topic"), - peers=[ - PrunePeerInfo(peer_id=b"alt1", signed_peer_record=b"rec1"), - PrunePeerInfo(peer_id=b"alt2"), - ], - backoff=120, - ) - assert ControlPrune.decode(prune.encode()) == prune - - def test_prune_no_peers(self) -> None: - """ControlPrune without peers field.""" - prune = ControlPrune(topic_id=TopicId("/topic"), backoff=60) - assert ControlPrune.decode(prune.encode()) == prune - - class TestSkipField: """Tests for _skip_field across all wire types.""" diff --git a/tests/lean_spec/subspecs/networking/transport/identity/test_signature.py b/tests/lean_spec/subspecs/networking/transport/identity/test_signature.py deleted file mode 100644 index 8f9230d05..000000000 --- a/tests/lean_spec/subspecs/networking/transport/identity/test_signature.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Tests for identity proof signatures.""" - -import os - -from lean_spec.subspecs.networking.transport.identity import ( - NOISE_IDENTITY_PREFIX, - IdentityKeypair, - create_identity_proof, - verify_identity_proof, -) -from lean_spec.types import Bytes32 - - -class TestIdentityProof: - """Tests for identity proof creation and verification.""" - - def test_create_and_verify(self) -> None: - """Identity proof can be verified.""" - identity_key = IdentityKeypair.generate() - public_key = Bytes32(os.urandom(32)) - - proof = create_identity_proof(identity_key, public_key) - assert verify_identity_proof( - identity_key.public_key, - public_key, - proof, - ) - - def test_verify_wrong_key(self) -> None: - """Verification fails with wrong public key.""" - identity_key = IdentityKeypair.generate() - public_key = Bytes32(os.urandom(32)) - wrong_key = Bytes32(os.urandom(32)) - - proof = create_identity_proof(identity_key, public_key) - assert not verify_identity_proof( - identity_key.public_key, - wrong_key, - proof, - ) - - def test_verify_wrong_identity_key(self) -> None: - """Verification fails with wrong identity key.""" - identity_key1 = IdentityKeypair.generate() - identity_key2 = IdentityKeypair.generate() - public_key = Bytes32(os.urandom(32)) - - proof = create_identity_proof(identity_key1, public_key) - assert not verify_identity_proof( - identity_key2.public_key, - public_key, - proof, - ) - - def test_proof_is_deterministic(self) -> None: - """Same inputs produce same proof format (but not same bytes due to ECDSA k).""" - identity_key = IdentityKeypair.generate() - public_key = Bytes32(os.urandom(32)) - - proof1 = create_identity_proof(identity_key, public_key) - proof2 = create_identity_proof(identity_key, public_key) - - assert verify_identity_proof(identity_key.public_key, public_key, proof1) - assert verify_identity_proof(identity_key.public_key, public_key, proof2) - - def test_noise_identity_prefix(self) -> None: - """NOISE_IDENTITY_PREFIX matches libp2p-noise spec.""" - assert NOISE_IDENTITY_PREFIX == b"noise-libp2p-static-key:" - - def test_proof_binds_identity_to_key(self) -> None: - """Proof prevents identity key substitution.""" - identity_key_real = IdentityKeypair.generate() - identity_key_attacker = IdentityKeypair.generate() - public_key = Bytes32(os.urandom(32)) - - proof = create_identity_proof(identity_key_real, public_key) - - assert verify_identity_proof( - identity_key_real.public_key, - public_key, - proof, - ) - assert not verify_identity_proof( - identity_key_attacker.public_key, - public_key, - proof, - ) diff --git a/tests/lean_spec/subspecs/networking/transport/quic/test_connection.py b/tests/lean_spec/subspecs/networking/transport/quic/test_connection.py index 513f8e678..f019b4c5b 100644 --- a/tests/lean_spec/subspecs/networking/transport/quic/test_connection.py +++ b/tests/lean_spec/subspecs/networking/transport/quic/test_connection.py @@ -86,7 +86,6 @@ class TestIsQuicMultiaddr: ("/ip4/127.0.0.1/udp/9000/quic-v1", True), ("/ip4/10.0.0.1/udp/4001/quic", True), ("/ip4/0.0.0.0/udp/9000/quic-v1/p2p/peerA", True), - ("/ip6/::1/udp/9000/quic-v1", True), # Not QUIC ("/ip4/127.0.0.1/tcp/9000", False), ("/ip4/127.0.0.1/udp/9000", False), @@ -101,7 +100,6 @@ class TestIsQuicMultiaddr: "quic-v1", "quic-legacy", "quic-v1-with-peer", - "ipv6-quic-v1", "tcp-not-quic", "udp-only-not-quic", "empty-string", @@ -132,15 +130,6 @@ def test_standard_quic_v1(self) -> None: None, ) - def test_ipv6_quic_v1(self) -> None: - """IPv6 QUIC multiaddr is parsed correctly per the libp2p-QUIC spec.""" - assert parse_multiaddr("/ip6/::1/udp/9000/quic-v1") == ( - "::1", - 9000, - "quic", - None, - ) - def test_with_peer_id(self, peer_a: PeerId) -> None: """Multiaddr with p2p component includes the parsed peer ID.""" host, port, transport, parsed_peer = parse_multiaddr( diff --git a/tests/lean_spec/subspecs/networking/transport/quic/test_negotiation.py b/tests/lean_spec/subspecs/networking/transport/quic/test_negotiation.py index 5ce130c3a..5c261b569 100644 --- a/tests/lean_spec/subspecs/networking/transport/quic/test_negotiation.py +++ b/tests/lean_spec/subspecs/networking/transport/quic/test_negotiation.py @@ -53,89 +53,6 @@ def test_na_constant(self) -> None: assert NA == "na" -class TestNegotiateClient: - """Tests for client-side negotiation.""" - - async def test_client_accepts_first_protocol(self) -> None: - """Client successfully negotiates first proposed protocol.""" - client, server = _create_stream_pair() - - async def server_task() -> None: - await _read_message(server) - await _write_message(server, MULTISTREAM_PROTOCOL_ID) - protocol = await _read_message(server) - await _write_message(server, protocol) - - task = asyncio.create_task(server_task()) - result = await client.negotiate_client([GOSSIPSUB_ID]) - await task - assert result == GOSSIPSUB_ID - - async def test_client_tries_multiple_protocols(self) -> None: - """Client tries protocols until one is accepted.""" - client, server = _create_stream_pair() - - async def server_task() -> None: - await _read_message(server) - await _write_message(server, MULTISTREAM_PROTOCOL_ID) - await _read_message(server) - await _write_message(server, NA) - protocol = await _read_message(server) - await _write_message(server, protocol) - - task = asyncio.create_task(server_task()) - result = await client.negotiate_client([GOSSIPSUB_V12_ID, GOSSIPSUB_ID]) - await task - assert result == GOSSIPSUB_ID - - async def test_client_all_rejected(self) -> None: - """Client raises error when all protocols rejected.""" - client, server = _create_stream_pair() - - async def server_task() -> None: - await _read_message(server) - await _write_message(server, MULTISTREAM_PROTOCOL_ID) - await _read_message(server) - await _write_message(server, NA) - await _read_message(server) - await _write_message(server, NA) - - task = asyncio.create_task(server_task()) - with pytest.raises(NegotiationError, match="No protocols accepted"): - await client.negotiate_client([ProtocolId("/proto1"), ProtocolId("/proto2")]) - await task - - async def test_client_empty_protocols(self) -> None: - """Client raises error when no protocols provided.""" - stream, _ = _create_stream_pair() - with pytest.raises(NegotiationError, match="No protocols to negotiate"): - await stream.negotiate_client([]) - - async def test_client_invalid_header(self) -> None: - """Client raises error on invalid header.""" - client, server = _create_stream_pair() - await _write_message(server, "/wrong/1.0.0") - - with pytest.raises(NegotiationError, match="Invalid multistream header"): - await client.negotiate_client([GOSSIPSUB_ID]) - - async def test_client_unexpected_response(self) -> None: - """Client gets neither protocol nor na""" - client, server = _create_stream_pair() - - async def server_task() -> None: - await _read_message(server) - await _write_message(server, MULTISTREAM_PROTOCOL_ID) - await _read_message(server) - await _write_message(server, "/unexpected/response") - await _write_message(server, "<><><>") - - task = asyncio.create_task(server_task()) - with pytest.raises(NegotiationError, match="Unexpected response"): - await client.negotiate_client([GOSSIPSUB_ID]) - await task - - class TestNegotiateServer: """Tests for server-side negotiation.""" @@ -187,23 +104,6 @@ async def test_server_invalid_header(self) -> None: with pytest.raises(NegotiationError, match="Invalid multistream header"): await server.negotiate_server({GOSSIPSUB_ID}) - async def test_server_client_unsupported_server_supported(self) -> None: - """Client proposes unsupported, then supported protocol""" - server, client = _create_stream_pair() - - async def server_task() -> None: - await _read_message(server) - await _write_message(server, MULTISTREAM_PROTOCOL_ID) - await _read_message(server) - await _write_message(server, NA) - protocol = await _read_message(server) - await _write_message(server, protocol) - - task = asyncio.create_task(server_task()) - result = await client.negotiate_client([ProtocolId("/unsupported"), GOSSIPSUB_ID]) - await task - assert result == GOSSIPSUB_ID - async def test_server_too_many_attempts(self) -> None: """Client uses too many attempts""" server, client = _create_stream_pair() @@ -674,61 +574,6 @@ async def test_message_no_trailing_newline(self) -> None: await stream._read_negotiation_message() -class TestFullNegotiation: - """Integration tests for full negotiation scenarios.""" - - async def test_bidirectional_negotiation(self) -> None: - """Client and server negotiate successfully.""" - client, server = _create_stream_pair() - - async def client_task() -> str: - return await client.negotiate_client([GOSSIPSUB_ID, STATUS_ID]) - - async def server_task() -> str: - return await server.negotiate_server({GOSSIPSUB_ID, BLOCKS_BY_ROOT_ID}) - - client_result, server_result = await asyncio.gather( - client_task(), - server_task(), - ) - assert client_result == GOSSIPSUB_ID - assert server_result == GOSSIPSUB_ID - - async def test_negotiate_status(self) -> None: - """Negotiate status protocol.""" - client, server = _create_stream_pair() - - async def client_task() -> str: - return await client.negotiate_client([STATUS_ID]) - - async def server_task() -> str: - return await server.negotiate_server({STATUS_ID}) - - client_result, server_result = await asyncio.gather( - client_task(), - server_task(), - ) - assert client_result == STATUS_ID - assert server_result == STATUS_ID - - async def test_negotiate_with_fallback(self) -> None: - """Client falls back to second option when first rejected.""" - client, server = _create_stream_pair() - - async def client_task() -> str: - return await client.negotiate_client([GOSSIPSUB_V12_ID, GOSSIPSUB_ID]) - - async def server_task() -> str: - return await server.negotiate_server({GOSSIPSUB_ID}) - - client_result, server_result = await asyncio.gather( - client_task(), - server_task(), - ) - assert client_result == GOSSIPSUB_ID - assert server_result == GOSSIPSUB_ID - - @dataclass class _MockStream: """In-memory stream for testing. diff --git a/tests/lean_spec/test_cli.py b/tests/lean_spec/test_cli.py index 0f2a35b54..4cd76fba2 100644 --- a/tests/lean_spec/test_cli.py +++ b/tests/lean_spec/test_cli.py @@ -77,26 +77,6 @@ def _make_enr_with_udp(ip_bytes: bytes, udp_port: int) -> str: return f"enr:{b64_content}" -def _make_enr_with_ipv6_udp(ip6_bytes: bytes, udp_port: int) -> str: - """Create a properly signed ENR string with IPv6 and UDP port.""" - content_items: list[RLPItem] = [ - b"\x01", # seq = 1 - b"id", - b"v4", - b"ip6", - ip6_bytes, - b"secp256k1", - _TEST_COMPRESSED_PUBKEY, - b"udp", - udp_port.to_bytes(2, "big"), - ] - signature = _sign_enr_content(content_items) - - rlp_data = encode_rlp([signature] + content_items) - b64_content = base64.urlsafe_b64encode(rlp_data).decode("utf-8").rstrip("=") - return f"enr:{b64_content}" - - def _make_enr_without_udp(ip_bytes: bytes) -> str: """Create a properly signed ENR string with IPv4 but no UDP port.""" content_items: list[RLPItem] = [ @@ -117,12 +97,10 @@ def _make_enr_without_udp(ip_bytes: bytes) -> str: # Pre-built test ENRs ENR_WITH_UDP = _make_enr_with_udp(b"\xc0\xa8\x01\x01", 9000) # 192.168.1.1:9000 -ENR_WITH_IPV6_UDP = _make_enr_with_ipv6_udp(b"\x00" * 15 + b"\x01", 9000) # ::1:9000 ENR_WITHOUT_UDP = _make_enr_without_udp(b"\xc0\xa8\x01\x01") # 192.168.1.1, no UDP # Valid multiaddr strings (QUIC format) MULTIADDR_IPV4 = "/ip4/127.0.0.1/udp/9000/quic-v1" -MULTIADDR_IPV6 = "/ip6/::1/udp/9000/quic-v1" class TestResolveBootnode: @@ -131,7 +109,6 @@ class TestResolveBootnode: def test_resolve_multiaddr_unchanged(self) -> None: """Multiaddr strings are returned unchanged.""" assert resolve_bootnode(MULTIADDR_IPV4) == MULTIADDR_IPV4 - assert resolve_bootnode(MULTIADDR_IPV6) == MULTIADDR_IPV6 def test_resolve_arbitrary_multiaddr_unchanged(self) -> None: """Any non-ENR string passes through unchanged.""" @@ -144,13 +121,6 @@ def test_resolve_valid_enr_with_udp(self) -> None: result = resolve_bootnode(ENR_WITH_UDP) assert result == "/ip4/192.168.1.1/udp/9000/quic-v1" - def test_resolve_enr_ipv6(self) -> None: - """ENR with IPv6+UDP extracts QUIC multiaddr correctly.""" - result = resolve_bootnode(ENR_WITH_IPV6_UDP) - # IPv6 loopback ::1 formatted as full hex - assert "/ip6/" in result - assert "/udp/9000/quic-v1" in result - def test_resolve_enr_without_udp_raises(self) -> None: """ENR without UDP port raises ValueError.""" with pytest.raises(ValueError, match=r"no UDP connection info"):