diff --git a/src/lean_spec/__main__.py b/src/lean_spec/__main__.py index fc666070..d9b901ab 100644 --- a/src/lean_spec/__main__.py +++ b/src/lean_spec/__main__.py @@ -30,6 +30,7 @@ import sys import time from pathlib import Path +from typing import Final from lean_spec.subspecs.chain.config import ATTESTATION_COMMITTEE_COUNT from lean_spec.subspecs.containers import Block, BlockBody, Checkpoint, State @@ -55,7 +56,7 @@ # # Must match the fork string used by ream and other clients. # For devnet, this is "devnet0". -GOSSIP_FORK_DIGEST = "devnet0" +GOSSIP_FORK_DIGEST: Final = "devnet0" logger = logging.getLogger(__name__) diff --git a/src/lean_spec/config.py b/src/lean_spec/config.py index d9fae848..1072f47c 100644 --- a/src/lean_spec/config.py +++ b/src/lean_spec/config.py @@ -4,11 +4,14 @@ This module contains environment-specific settings that apply across all subspecs. """ +from __future__ import annotations + import os +from typing import Final _SUPPORTED_LEAN_ENVS: list[str] = ["prod", "test"] -LEAN_ENV = os.environ.get("LEAN_ENV", "prod").lower() +LEAN_ENV: Final = os.environ.get("LEAN_ENV", "prod").lower() """The environment flag ('prod' or 'test'). Defaults to 'prod' for the specs.""" if LEAN_ENV not in _SUPPORTED_LEAN_ENVS: diff --git a/src/lean_spec/snappy/__init__.py b/src/lean_spec/snappy/__init__.py index d7a29d51..2b6e0ad9 100644 --- a/src/lean_spec/snappy/__init__.py +++ b/src/lean_spec/snappy/__init__.py @@ -4,16 +4,6 @@ It prioritizes speed over compression ratio, making it ideal for real-time applications and network protocols. -Usage:: - - from lean_spec.snappy import compress, decompress - - # Compress data before sending - compressed = compress(data) - - # Decompress received data - original = decompress(compressed) - The implementation follows the Snappy format specification: https://github.com/google/snappy/blob/main/format_description.txt """ diff --git a/src/lean_spec/snappy/constants.py b/src/lean_spec/snappy/constants.py index cea137a6..76600a42 100644 --- a/src/lean_spec/snappy/constants.py +++ b/src/lean_spec/snappy/constants.py @@ -6,21 +6,23 @@ from __future__ import annotations +from typing import Final + # # Snappy processes data in fixed-size blocks to bound memory usage and # enable streaming. Each block is compressed independently. -BLOCK_LOG: int = 16 +BLOCK_LOG: Final = 16 """Log2 of the maximum block size (2^16 = 65536 bytes).""" -BLOCK_SIZE: int = 1 << BLOCK_LOG +BLOCK_SIZE: Final = 1 << BLOCK_LOG """Maximum block size in bytes (64 KB). Large inputs are split into 64 KB blocks, each compressed independently. This bounds memory usage and enables streaming decompression. """ -INPUT_MARGIN_BYTES: int = 15 +INPUT_MARGIN_BYTES: Final = 15 """Safety margin at end of input for batch reads. The compressor reads up to 8 bytes at a time for efficiency. This margin @@ -36,7 +38,7 @@ # 10 = Copy with 2-byte offset (max 65535 bytes back) # 11 = Copy with 4-byte offset (max 4GB back) -LITERAL: int = 0b00 +LITERAL: Final = 0b00 """Tag type for literal (uncompressed) data. Literals are sequences of bytes copied verbatim from input to output. @@ -44,7 +46,7 @@ Longer literals use 1-4 additional bytes for the length. """ -COPY_1_BYTE_OFFSET: int = 0b01 +COPY_1_BYTE_OFFSET: Final = 0b01 """Tag type for copy with 1-byte offset. Compact encoding for short backreferences: @@ -54,7 +56,7 @@ Total encoding: 2 bytes (tag + offset). """ -COPY_2_BYTE_OFFSET: int = 0b10 +COPY_2_BYTE_OFFSET: Final = 0b10 """Tag type for copy with 2-byte offset. Standard encoding for medium backreferences: @@ -64,7 +66,7 @@ Total encoding: 3 bytes (tag + 2 offset bytes). """ -COPY_4_BYTE_OFFSET: int = 0b11 +COPY_4_BYTE_OFFSET: Final = 0b11 """Tag type for copy with 4-byte offset. Extended encoding for long backreferences: @@ -79,13 +81,13 @@ # The compressor uses a hash table to find matching sequences in previously # seen data. The hash table maps 4-byte sequences to their positions. -MIN_HASH_TABLE_BITS: int = 8 +MIN_HASH_TABLE_BITS: Final = 8 """Minimum hash table size exponent (2^8 = 256 entries).""" -MAX_HASH_TABLE_BITS: int = 15 +MAX_HASH_TABLE_BITS: Final = 15 """Maximum hash table size exponent (2^15 = 32768 entries).""" -HASH_MULTIPLIER: int = 0x1E35A7BD +HASH_MULTIPLIER: Final = 0x1E35A7BD """Magic constant for the hash function. This is a prime-like constant that spreads input bits well across @@ -98,51 +100,51 @@ # 1-60 bytes: Length stored in upper 6 bits of tag byte. # 61+ bytes: Tag byte indicates extra length bytes follow. -MAX_INLINE_LITERAL_LENGTH: int = 60 +MAX_INLINE_LITERAL_LENGTH: Final = 60 """Maximum literal length that fits in the tag byte. For lengths 1-60, we encode (length - 1) in the upper 6 bits of the tag. For lengths > 60, we use additional bytes to encode the length. """ -LITERAL_LENGTH_1_BYTE: int = 60 +LITERAL_LENGTH_1_BYTE: Final = 60 """Tag marker indicating 1 additional byte for literal length. The actual length is stored as a single byte following the tag. Supports literals up to 256 bytes. """ -LITERAL_LENGTH_2_BYTES: int = 61 +LITERAL_LENGTH_2_BYTES: Final = 61 """Tag marker indicating 2 additional bytes for literal length. The actual length is stored as little-endian uint16 following the tag. Supports literals up to 65536 bytes. """ -LITERAL_LENGTH_3_BYTES: int = 62 +LITERAL_LENGTH_3_BYTES: Final = 62 """Tag marker indicating 3 additional bytes for literal length. The actual length is stored as little-endian uint24 following the tag. Supports literals up to 16777216 bytes. """ -LITERAL_LENGTH_4_BYTES: int = 63 +LITERAL_LENGTH_4_BYTES: Final = 63 """Tag marker indicating 4 additional bytes for literal length. The actual length is stored as little-endian uint32 following the tag. Supports literals up to 4294967296 bytes. """ -MAX_COPY_1_LENGTH: int = 11 +MAX_COPY_1_LENGTH: Final = 11 """Maximum copy length for 1-byte offset encoding (4-11 bytes).""" -MIN_COPY_1_LENGTH: int = 4 +MIN_COPY_1_LENGTH: Final = 4 """Minimum copy length for 1-byte offset encoding.""" -MAX_COPY_1_OFFSET: int = 2047 +MAX_COPY_1_OFFSET: Final = 2047 """Maximum offset for 1-byte offset encoding (11 bits).""" -MAX_COPY_2_OFFSET: int = 65535 +MAX_COPY_2_OFFSET: Final = 65535 """Maximum offset for 2-byte offset encoding (16 bits).""" # @@ -150,15 +152,15 @@ # compressed data. Varints use 7 bits per byte, with the high bit # indicating continuation. -MAX_VARINT_LENGTH: int = 5 +MAX_VARINT_LENGTH: Final = 5 """Maximum bytes needed for a 32-bit varint. Each byte encodes 7 bits, so 5 bytes can encode up to 35 bits. This is sufficient for any 32-bit value. """ -VARINT_CONTINUATION_BIT: int = 0x80 +VARINT_CONTINUATION_BIT: Final = 0x80 """High bit set in varint bytes to indicate more bytes follow.""" -VARINT_DATA_MASK: int = 0x7F +VARINT_DATA_MASK: Final = 0x7F """Mask to extract the 7 data bits from a varint byte.""" diff --git a/src/lean_spec/snappy/framing.py b/src/lean_spec/snappy/framing.py index 31224cc6..86e58106 100644 --- a/src/lean_spec/snappy/framing.py +++ b/src/lean_spec/snappy/framing.py @@ -87,11 +87,13 @@ from __future__ import annotations +from typing import Final + from .compress import compress as raw_compress from .decompress import SnappyDecompressionError from .decompress import decompress as raw_decompress -STREAM_IDENTIFIER: bytes = b"\xff\x06\x00\x00sNaPpY" +STREAM_IDENTIFIER: Final = b"\xff\x06\x00\x00sNaPpY" """Stream identifier marking the start of a Snappy framed stream. Format: [type=0xff][length=6 as 3-byte LE][magic="sNaPpY"] @@ -100,28 +102,28 @@ It may also appear later (e.g., when streams are concatenated). """ -CHUNK_TYPE_COMPRESSED: int = 0x00 +CHUNK_TYPE_COMPRESSED: Final = 0x00 """Chunk type for Snappy-compressed data. Chunk data format: [masked_crc32c: 4 bytes LE][compressed_payload] The CRC covers the UNCOMPRESSED data, not the compressed payload. """ -CHUNK_TYPE_UNCOMPRESSED: int = 0x01 +CHUNK_TYPE_UNCOMPRESSED: Final = 0x01 """Chunk type for uncompressed (raw) data. Chunk data format: [masked_crc32c: 4 bytes LE][raw_payload] Used when compression would expand the data (e.g., random bytes). """ -MAX_UNCOMPRESSED_CHUNK_SIZE: int = 65536 +MAX_UNCOMPRESSED_CHUNK_SIZE: Final = 65536 """Maximum uncompressed data per chunk (64 KiB). This limit enables fixed-size decompression buffers. Chunks exceeding this limit are rejected. """ -CRC32C_MASK_DELTA: int = 0xA282EAD8 +CRC32C_MASK_DELTA: Final = 0xA282EAD8 """Constant added during CRC masking. From the spec: "Rotate right by 15 bits, then add 0xa282ead8." @@ -167,7 +169,7 @@ def _crc32c_table() -> list[int]: # Pre-compute the table at module load time. -_CRC32C_TABLE: list[int] = _crc32c_table() +_CRC32C_TABLE: tuple[int, ...] = tuple(_crc32c_table()) def _crc32c(data: bytes) -> int: diff --git a/src/lean_spec/subspecs/api/endpoints/health.py b/src/lean_spec/subspecs/api/endpoints/health.py index 6d7129f6..cd6206fb 100644 --- a/src/lean_spec/subspecs/api/endpoints/health.py +++ b/src/lean_spec/subspecs/api/endpoints/health.py @@ -3,13 +3,14 @@ from __future__ import annotations import json +from typing import Final from aiohttp import web -STATUS_HEALTHY = "healthy" +STATUS_HEALTHY: Final = "healthy" """Fixed healthy status returned by the health endpoint.""" -SERVICE_NAME = "lean-rpc-api" +SERVICE_NAME: Final = "lean-rpc-api" """Fixed service identifier returned by the health endpoint.""" diff --git a/src/lean_spec/subspecs/api/routes.py b/src/lean_spec/subspecs/api/routes.py index 8565ddff..ed08af6b 100644 --- a/src/lean_spec/subspecs/api/routes.py +++ b/src/lean_spec/subspecs/api/routes.py @@ -1,5 +1,7 @@ """API route definitions.""" +from __future__ import annotations + from collections.abc import Awaitable, Callable from aiohttp import web diff --git a/src/lean_spec/subspecs/chain/clock.py b/src/lean_spec/subspecs/chain/clock.py index e8a2f44a..5e98d03d 100644 --- a/src/lean_spec/subspecs/chain/clock.py +++ b/src/lean_spec/subspecs/chain/clock.py @@ -8,6 +8,8 @@ coordinate block proposals and attestations. """ +from __future__ import annotations + import asyncio from collections.abc import Callable from dataclasses import dataclass diff --git a/src/lean_spec/subspecs/chain/config.py b/src/lean_spec/subspecs/chain/config.py index 45d8aeeb..29b5bf9f 100644 --- a/src/lean_spec/subspecs/chain/config.py +++ b/src/lean_spec/subspecs/chain/config.py @@ -4,7 +4,7 @@ from lean_spec.types.uint import Uint64 -INTERVALS_PER_SLOT = Uint64(5) +INTERVALS_PER_SLOT: Final = Uint64(5) """Number of intervals per slot for forkchoice processing.""" SECONDS_PER_SLOT: Final = Uint64(4) @@ -13,7 +13,7 @@ MILLISECONDS_PER_SLOT: Final = SECONDS_PER_SLOT * Uint64(1000) """The fixed duration of a single slot in milliseconds.""" -MILLISECONDS_PER_INTERVAL = MILLISECONDS_PER_SLOT // INTERVALS_PER_SLOT +MILLISECONDS_PER_INTERVAL: Final = MILLISECONDS_PER_SLOT // INTERVALS_PER_SLOT """Milliseconds per forkchoice processing interval.""" JUSTIFICATION_LOOKBACK_SLOTS: Final = Uint64(3) diff --git a/src/lean_spec/subspecs/containers/slot.py b/src/lean_spec/subspecs/containers/slot.py index 84ce5b4d..344154ff 100644 --- a/src/lean_spec/subspecs/containers/slot.py +++ b/src/lean_spec/subspecs/containers/slot.py @@ -3,10 +3,11 @@ from __future__ import annotations import math +from typing import Final from lean_spec.types import Uint64 -IMMEDIATE_JUSTIFICATION_WINDOW = 5 +IMMEDIATE_JUSTIFICATION_WINDOW: Final = 5 """First N slots after finalization are always justifiable.""" diff --git a/src/lean_spec/subspecs/containers/state/state.py b/src/lean_spec/subspecs/containers/state/state.py index 4b54b08d..30532b3f 100644 --- a/src/lean_spec/subspecs/containers/state/state.py +++ b/src/lean_spec/subspecs/containers/state/state.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, AbstractSet, Collection, Iterable +from collections.abc import Collection, Iterable +from collections.abc import Set as AbstractSet +from typing import TYPE_CHECKING from lean_spec.subspecs.chain.clock import Interval from lean_spec.subspecs.chain.config import INTERVALS_PER_SLOT diff --git a/src/lean_spec/subspecs/koalabear/field.py b/src/lean_spec/subspecs/koalabear/field.py index ed71930a..4e43f813 100644 --- a/src/lean_spec/subspecs/koalabear/field.py +++ b/src/lean_spec/subspecs/koalabear/field.py @@ -1,30 +1,32 @@ """Core definition of the KoalaBear prime field Fp.""" -from typing import IO, Self +from __future__ import annotations + +from typing import IO, Final, Self from lean_spec.types import SSZType -P: int = 2**31 - 2**24 + 1 +P: Final = 2**31 - 2**24 + 1 """ The KoalaBear Prime: P = 2^31 - 2^24 + 1 The prime is chosen because the cube map (x -> x^3) is an automorphism of the multiplicative group. """ -P_BITS: int = 31 +P_BITS: Final = 31 """The number of bits in the prime P.""" -P_BYTES: int = (P_BITS + 7) // 8 +P_BYTES: Final = (P_BITS + 7) // 8 """The size of a KoalaBear field element in bytes.""" -TWO_ADICITY: int = 24 +TWO_ADICITY: Final = 24 """ The largest integer n such that 2^n divides (P - 1). P - 1 = 2^24 * 127 """ -TWO_ADIC_GENERATORS: list[int] = [ +TWO_ADIC_GENERATORS: Final[list[int]] = [ 0x1, 0x7F000000, 0x7E010002, diff --git a/src/lean_spec/subspecs/networking/client/event_source.py b/src/lean_spec/subspecs/networking/client/event_source.py index 489c270c..796a5d2c 100644 --- a/src/lean_spec/subspecs/networking/client/event_source.py +++ b/src/lean_spec/subspecs/networking/client/event_source.py @@ -103,7 +103,7 @@ import asyncio import logging from dataclasses import dataclass, field -from typing import Protocol, Self +from typing import Final, Protocol, Self from lean_spec.snappy import SnappyDecompressionError, frame_decompress from lean_spec.subspecs.containers import SignedBlockWithAttestation @@ -187,7 +187,7 @@ class GossipMessageError(Exception): """Raised when a gossip message cannot be processed.""" -SUPPORTED_PROTOCOLS: frozenset[str] = ( +SUPPORTED_PROTOCOLS: Final[frozenset[str]] = ( frozenset({GOSSIPSUB_DEFAULT_PROTOCOL_ID, GOSSIPSUB_PROTOCOL_ID_V12}) | REQRESP_PROTOCOL_IDS ) """Protocols supported for incoming stream negotiation. diff --git a/src/lean_spec/subspecs/networking/config.py b/src/lean_spec/subspecs/networking/config.py index 8750dc3e..33ed6855 100644 --- a/src/lean_spec/subspecs/networking/config.py +++ b/src/lean_spec/subspecs/networking/config.py @@ -1,19 +1,17 @@ """Networking Configuration Constants.""" +from __future__ import annotations + from typing import Final from .types import DomainType -# --- Request/Response Limits --- - MAX_REQUEST_BLOCKS: Final[int] = 2**10 """Maximum number of blocks in a single request (1024).""" MAX_PAYLOAD_SIZE: Final[int] = 10 * 1024 * 1024 """Maximum uncompressed payload size in bytes (10 MiB).""" -# --- Timeouts (in seconds) --- - TTFB_TIMEOUT: Final[float] = 5.0 """Time-to-first-byte timeout. @@ -28,8 +26,6 @@ response, including all chunks for multi-part responses like BlocksByRange. """ -# --- Gossip Message Domains --- - MESSAGE_DOMAIN_INVALID_SNAPPY: Final[DomainType] = DomainType(b"\x00") """1-byte domain for gossip message-id isolation of invalid snappy messages. @@ -42,8 +38,6 @@ Per Ethereum spec, prepended to the message hash when decompression succeeds. """ -# --- Gossipsub Protocol IDs --- - GOSSIPSUB_PROTOCOL_ID_V11: Final[str] = "/meshsub/1.1.0" """Gossipsub v1.1 protocol ID - peer scoring, extended validators. @@ -61,8 +55,6 @@ "Clients MUST support the gossipsub v1 libp2p Protocol including the gossipsub v1.1 extension." """ -# --- Gossipsub Parameters --- - PRUNE_BACKOFF: Final[int] = 60 """Default PRUNE backoff duration in seconds. diff --git a/src/lean_spec/subspecs/networking/discovery/codec.py b/src/lean_spec/subspecs/networking/discovery/codec.py index 2597f0c4..92ea9f2b 100644 --- a/src/lean_spec/subspecs/networking/discovery/codec.py +++ b/src/lean_spec/subspecs/networking/discovery/codec.py @@ -20,16 +20,16 @@ from __future__ import annotations -import os - from lean_spec.subspecs.networking.types import SeqNumber -from lean_spec.types import Uint64, decode_rlp, encode_rlp +from lean_spec.types import RLPItem, Uint64, decode_rlp, decode_rlp_list, encode_rlp from lean_spec.types.rlp import RLPDecodingError from lean_spec.types.uint import Uint8 from .messages import ( Distance, FindNode, + IPv4, + IPv6, MessageType, Nodes, Ping, @@ -160,8 +160,8 @@ def _encode_ping(msg: Ping) -> bytes: def _decode_ping(payload: bytes) -> Ping: """Decode PING message.""" - items = decode_rlp(payload) - if not isinstance(items, list) or len(items) != 2: + items = decode_rlp_list(payload) + if len(items) != 2: raise MessageDecodingError("PING requires 2 elements") return Ping( @@ -183,17 +183,18 @@ def _encode_pong(msg: Pong) -> bytes: def _decode_pong(payload: bytes) -> Pong: """Decode PONG message.""" - items = decode_rlp(payload) - if not isinstance(items, list) or len(items) != 4: + items = decode_rlp_list(payload) + if len(items) != 4: raise MessageDecodingError("PONG requires 4 elements") - port_bytes = items[3] - port = int.from_bytes(port_bytes, "big") if port_bytes else 0 + port = int.from_bytes(items[3], "big") if items[3] else 0 + ip_bytes = items[2] + recipient_ip = IPv4(ip_bytes) if len(ip_bytes) == IPv4.LENGTH else IPv6(ip_bytes) return Pong( request_id=_decode_request_id(items[0]), enr_seq=SeqNumber(_decode_uint64(items[1])), - recipient_ip=items[2], + recipient_ip=recipient_ip, recipient_port=Port(port), ) @@ -214,6 +215,10 @@ def _decode_findnode(payload: bytes) -> FindNode: if not isinstance(items, list) or len(items) != 2: raise MessageDecodingError("FINDNODE requires 2 elements") + request_id_raw = items[0] + if not isinstance(request_id_raw, bytes): + raise MessageDecodingError("FINDNODE request-id must be bytes") + distances_raw = items[1] if not isinstance(distances_raw, list): raise MessageDecodingError("FINDNODE distances must be a list") @@ -221,17 +226,18 @@ def _decode_findnode(payload: bytes) -> FindNode: distances = [Distance(int.from_bytes(d, "big") if d else 0) for d in distances_raw] return FindNode( - request_id=_decode_request_id(items[0]), + request_id=_decode_request_id(request_id_raw), distances=distances, ) def _encode_nodes(msg: Nodes) -> bytes: """Encode NODES message.""" - items = [ + enrs: list[RLPItem] = list(msg.enrs) + items: list[RLPItem] = [ _encode_request_id(msg.request_id), bytes([int(msg.total)]) if int(msg.total) > 0 else b"", - msg.enrs, + enrs, ] return bytes([MessageType.NODES]) + encode_rlp(items) @@ -242,8 +248,14 @@ def _decode_nodes(payload: bytes) -> Nodes: if not isinstance(items, list) or len(items) != 3: raise MessageDecodingError("NODES requires 3 elements") - total_bytes = items[1] - total = total_bytes[0] if total_bytes else 0 + request_id_raw = items[0] + if not isinstance(request_id_raw, bytes): + raise MessageDecodingError("NODES request-id must be bytes") + + total_raw = items[1] + if not isinstance(total_raw, bytes): + raise MessageDecodingError("NODES total must be bytes") + total = total_raw[0] if total_raw else 0 enrs_raw = items[2] if not isinstance(enrs_raw, list): @@ -252,7 +264,7 @@ def _decode_nodes(payload: bytes) -> Nodes: enrs = [e if isinstance(e, bytes) else b"" for e in enrs_raw] return Nodes( - request_id=_decode_request_id(items[0]), + request_id=_decode_request_id(request_id_raw), total=Uint8(total), enrs=enrs, ) @@ -270,8 +282,8 @@ def _encode_talkreq(msg: TalkReq) -> bytes: def _decode_talkreq(payload: bytes) -> TalkReq: """Decode TALKREQ message.""" - items = decode_rlp(payload) - if not isinstance(items, list) or len(items) != 3: + items = decode_rlp_list(payload) + if len(items) != 3: raise MessageDecodingError("TALKREQ requires 3 elements") return TalkReq( @@ -292,16 +304,11 @@ def _encode_talkresp(msg: TalkResp) -> bytes: def _decode_talkresp(payload: bytes) -> TalkResp: """Decode TALKRESP message.""" - items = decode_rlp(payload) - if not isinstance(items, list) or len(items) != 2: + items = decode_rlp_list(payload) + if len(items) != 2: raise MessageDecodingError("TALKRESP requires 2 elements") return TalkResp( request_id=_decode_request_id(items[0]), response=items[1], ) - - -def generate_request_id() -> RequestId: - """Generate a random request ID.""" - return RequestId(data=os.urandom(8)) diff --git a/src/lean_spec/subspecs/networking/discovery/config.py b/src/lean_spec/subspecs/networking/discovery/config.py index 246743b8..ee4f3ef3 100644 --- a/src/lean_spec/subspecs/networking/discovery/config.py +++ b/src/lean_spec/subspecs/networking/discovery/config.py @@ -7,6 +7,8 @@ - https://github.com/ethereum/devp2p/blob/master/discv5/discv5-theory.md """ +from __future__ import annotations + from typing import Final from lean_spec.types import StrictBaseModel diff --git a/src/lean_spec/subspecs/networking/discovery/crypto.py b/src/lean_spec/subspecs/networking/discovery/crypto.py index e9fcf29a..900828a4 100644 --- a/src/lean_spec/subspecs/networking/discovery/crypto.py +++ b/src/lean_spec/subspecs/networking/discovery/crypto.py @@ -35,25 +35,25 @@ from lean_spec.types import Bytes12, Bytes16, Bytes32, Bytes33, Bytes64, Bytes65 -COMPRESSED_PUBKEY_SIZE = 33 +COMPRESSED_PUBKEY_SIZE: Final = 33 """Compressed secp256k1 public key: 0x02/0x03 + 32-byte x coordinate.""" -UNCOMPRESSED_PUBKEY_SIZE = 65 +UNCOMPRESSED_PUBKEY_SIZE: Final = 65 """Uncompressed secp256k1 public key: 0x04 + 32-byte x + 32-byte y.""" -AES_KEY_SIZE = 16 +AES_KEY_SIZE: Final = 16 """AES-128 key size in bytes.""" -GCM_NONCE_SIZE = 12 +GCM_NONCE_SIZE: Final = 12 """AES-GCM nonce size in bytes.""" -GCM_TAG_SIZE = 16 +GCM_TAG_SIZE: Final = 16 """AES-GCM authentication tag size in bytes.""" -CTR_IV_SIZE = 16 +CTR_IV_SIZE: Final = 16 """AES-CTR initialization vector size in bytes.""" -ID_SIGNATURE_SIZE = 64 +ID_SIGNATURE_SIZE: Final = 64 """secp256k1 signature size (r || s, each 32 bytes).""" ID_SIGNATURE_DOMAIN: Final = b"discovery v5 identity proof" diff --git a/src/lean_spec/subspecs/networking/discovery/handshake.py b/src/lean_spec/subspecs/networking/discovery/handshake.py index 146e15ab..1979b31b 100644 --- a/src/lean_spec/subspecs/networking/discovery/handshake.py +++ b/src/lean_spec/subspecs/networking/discovery/handshake.py @@ -29,6 +29,7 @@ from dataclasses import dataclass, field from enum import Enum, auto from threading import Lock +from typing import Final from lean_spec.subspecs.networking.enr import ENR from lean_spec.subspecs.networking.types import NodeId, SeqNumber @@ -48,17 +49,16 @@ encode_handshake_authdata, encode_static_header, encode_whoareyou_authdata, - generate_id_nonce, ) from .session import Session, SessionCache -_DEFAULT_PORT = Port(0) +_DEFAULT_PORT: Final = Port(0) """Default port value for optional port parameters.""" -MAX_PENDING_HANDSHAKES = 100 +MAX_PENDING_HANDSHAKES: Final = 100 """Hard cap on concurrent pending handshakes to prevent resource exhaustion.""" -MAX_ENR_CACHE = 1000 +MAX_ENR_CACHE: Final = 1000 """Maximum number of cached ENRs.""" @@ -223,7 +223,7 @@ def create_whoareyou( - nonce: The request_nonce to use in the packet header - challenge_data: Full data for key derivation (masking-iv || static-header || authdata) """ - id_nonce = generate_id_nonce() + id_nonce = IdNonce.generate() authdata = encode_whoareyou_authdata(id_nonce, remote_enr_seq) # Build challenge_data per spec: masking-iv || static-header || authdata. diff --git a/src/lean_spec/subspecs/networking/discovery/keys.py b/src/lean_spec/subspecs/networking/discovery/keys.py index dc974823..60bbda8f 100644 --- a/src/lean_spec/subspecs/networking/discovery/keys.py +++ b/src/lean_spec/subspecs/networking/discovery/keys.py @@ -27,6 +27,7 @@ import hashlib import hmac +from typing import Final from Crypto.Hash import keccak @@ -34,10 +35,10 @@ from .crypto import ecdh_agree, pubkey_to_uncompressed -DISCV5_KEY_AGREEMENT_INFO = b"discovery v5 key agreement" +DISCV5_KEY_AGREEMENT_INFO: Final = b"discovery v5 key agreement" """Info string used in HKDF expansion for Discovery v5 key derivation.""" -SESSION_KEY_SIZE = 16 +SESSION_KEY_SIZE: Final = 16 """Size of each session key in bytes (AES-128).""" diff --git a/src/lean_spec/subspecs/networking/discovery/messages.py b/src/lean_spec/subspecs/networking/discovery/messages.py index b7feeda3..491ff4f6 100644 --- a/src/lean_spec/subspecs/networking/discovery/messages.py +++ b/src/lean_spec/subspecs/networking/discovery/messages.py @@ -16,21 +16,22 @@ from __future__ import annotations +import os from enum import IntEnum -from typing import ClassVar +from typing import ClassVar, Final, Self from lean_spec.subspecs.networking.types import SeqNumber from lean_spec.types import StrictBaseModel from lean_spec.types.byte_arrays import BaseByteList, BaseBytes from lean_spec.types.uint import Uint8, Uint16 -PROTOCOL_ID: bytes = b"discv5" +PROTOCOL_ID: Final[bytes] = b"discv5" """Protocol identifier in packet header. 6 bytes.""" -PROTOCOL_VERSION: int = 0x0001 +PROTOCOL_VERSION: Final[int] = 0x0001 """Current protocol version (v5.1).""" -MAX_REQUEST_ID_LENGTH: int = 8 +MAX_REQUEST_ID_LENGTH: Final[int] = 8 """Maximum length of request-id in bytes.""" @@ -44,6 +45,11 @@ class RequestId(BaseByteList): LIMIT: ClassVar[int] = MAX_REQUEST_ID_LENGTH + @classmethod + def generate(cls) -> Self: + """Generate a random request ID.""" + return cls(data=os.urandom(8)) + class IPv4(BaseBytes): """IPv4 address as 4 bytes.""" @@ -66,6 +72,11 @@ class IdNonce(BaseBytes): LENGTH: ClassVar[int] = 16 + @classmethod + def generate(cls) -> Self: + """Generate a random 16-byte identity challenge nonce.""" + return cls(os.urandom(16)) + class Nonce(BaseBytes): """ @@ -76,6 +87,11 @@ class Nonce(BaseBytes): LENGTH: ClassVar[int] = 12 + @classmethod + def generate(cls) -> Self: + """Generate a random 12-byte message nonce.""" + return cls(os.urandom(cls.LENGTH)) + class Distance(Uint16): """Log2 distance (0-256). Distance 0 returns the node's own ENR.""" diff --git a/src/lean_spec/subspecs/networking/discovery/packet.py b/src/lean_spec/subspecs/networking/discovery/packet.py index 18b73dab..c8b05b8b 100644 --- a/src/lean_spec/subspecs/networking/discovery/packet.py +++ b/src/lean_spec/subspecs/networking/discovery/packet.py @@ -32,6 +32,7 @@ import os import struct from dataclasses import dataclass +from typing import Final from lean_spec.subspecs.networking.types import NodeId, SeqNumber from lean_spec.types import Bytes12, Bytes16, Bytes33, Bytes64 @@ -40,7 +41,6 @@ from .crypto import ( AES_KEY_SIZE, CTR_IV_SIZE, - GCM_NONCE_SIZE, aes_ctr_decrypt, aes_ctr_encrypt, aes_gcm_decrypt, @@ -48,16 +48,16 @@ ) from .messages import PROTOCOL_ID, PROTOCOL_VERSION, IdNonce, Nonce, PacketFlag -STATIC_HEADER_SIZE = 23 +STATIC_HEADER_SIZE: Final = 23 """Size of the static header in bytes: 6 + 2 + 1 + 12 + 2.""" -MESSAGE_AUTHDATA_SIZE = 32 +MESSAGE_AUTHDATA_SIZE: Final = 32 """Authdata size for MESSAGE packets: src-id (32 bytes).""" -WHOAREYOU_AUTHDATA_SIZE = 24 +WHOAREYOU_AUTHDATA_SIZE: Final = 24 """Authdata size for WHOAREYOU packets: id-nonce (16) + enr-seq (8).""" -HANDSHAKE_HEADER_SIZE = 34 +HANDSHAKE_HEADER_SIZE: Final = 34 """Fixed portion of handshake authdata: src-id (32) + sig-size (1) + eph-key-size (1).""" @@ -362,16 +362,6 @@ def encode_handshake_authdata( return authdata -def generate_nonce() -> Nonce: - """Generate a random 12-byte message nonce.""" - return Nonce(os.urandom(GCM_NONCE_SIZE)) - - -def generate_id_nonce() -> IdNonce: - """Generate a random 16-byte identity challenge nonce.""" - return IdNonce(os.urandom(16)) - - def encode_static_header(flag: PacketFlag, nonce: Nonce, authdata_size: int) -> bytes: """Encode the 23-byte static header.""" return ( diff --git a/src/lean_spec/subspecs/networking/discovery/service.py b/src/lean_spec/subspecs/networking/discovery/service.py index 8e17d3f5..370f859b 100644 --- a/src/lean_spec/subspecs/networking/discovery/service.py +++ b/src/lean_spec/subspecs/networking/discovery/service.py @@ -30,6 +30,7 @@ import random from collections.abc import Callable from dataclasses import dataclass +from typing import Final from lean_spec.subspecs.networking.enr import ENR from lean_spec.subspecs.networking.types import NodeId, SeqNumber @@ -46,13 +47,13 @@ logger = logging.getLogger(__name__) -LOOKUP_PARALLELISM = ALPHA +LOOKUP_PARALLELISM: Final = ALPHA """Number of concurrent FINDNODE queries during lookup.""" -REFRESH_INTERVAL_SECS = 3600 +REFRESH_INTERVAL_SECS: Final = 3600 """Interval between routing table refresh lookups (1 hour).""" -REVALIDATION_INTERVAL_SECS = 300 +REVALIDATION_INTERVAL_SECS: Final = 300 """Interval between node liveness revalidation (5 minutes).""" diff --git a/src/lean_spec/subspecs/networking/discovery/session.py b/src/lean_spec/subspecs/networking/discovery/session.py index f9fdffd9..ddbf6974 100644 --- a/src/lean_spec/subspecs/networking/discovery/session.py +++ b/src/lean_spec/subspecs/networking/discovery/session.py @@ -23,6 +23,7 @@ import time from dataclasses import dataclass, field from threading import Lock +from typing import Final from lean_spec.subspecs.networking.types import NodeId from lean_spec.types import Bytes16 @@ -30,13 +31,13 @@ from .config import BOND_EXPIRY_SECS from .messages import Port -_DEFAULT_PORT = Port(0) +_DEFAULT_PORT: Final = Port(0) """Default port value for optional port parameters.""" -DEFAULT_SESSION_TIMEOUT_SECS = 86400 +DEFAULT_SESSION_TIMEOUT_SECS: Final = 86400 """Default session timeout (24 hours).""" -MAX_SESSIONS = 1000 +MAX_SESSIONS: Final = 1000 """Maximum number of cached sessions to prevent memory exhaustion.""" diff --git a/src/lean_spec/subspecs/networking/discovery/transport.py b/src/lean_spec/subspecs/networking/discovery/transport.py index b9d9f449..1306709d 100644 --- a/src/lean_spec/subspecs/networking/discovery/transport.py +++ b/src/lean_spec/subspecs/networking/discovery/transport.py @@ -32,7 +32,6 @@ MessageDecodingError, decode_message, encode_message, - generate_request_id, ) from .config import DiscoveryConfig from .handshake import HandshakeError, HandshakeManager @@ -45,6 +44,7 @@ Ping, Pong, Port, + RequestId, TalkReq, TalkResp, ) @@ -58,7 +58,6 @@ encode_message_authdata, encode_packet, encode_static_header, - generate_nonce, ) from .session import SessionCache @@ -300,7 +299,7 @@ async def send_ping(self, dest_node_id: NodeId, dest_addr: tuple[str, int]) -> P Returns: PONG response or None on timeout. """ - request_id = generate_request_id() + request_id = RequestId.generate() ping = Ping( request_id=request_id, enr_seq=SeqNumber(self._local_enr.seq), @@ -332,7 +331,7 @@ async def send_findnode( Returns: List of RLP-encoded ENRs from all NODES responses. """ - request_id = generate_request_id() + request_id = RequestId.generate() findnode = FindNode( request_id=request_id, distances=[Distance(d) for d in distances], @@ -376,7 +375,7 @@ async def _send_multi_response_request( self._node_addresses[dest_node_id] = dest_addr # Build and send packet. - nonce = generate_nonce() + nonce = Nonce.generate() message_bytes = encode_message(message) packet = self._build_message_packet(dest_node_id, dest_addr, nonce, message_bytes) @@ -455,7 +454,7 @@ async def send_talkreq( Returns: Response payload or None on timeout/error. """ - request_id = generate_request_id() + request_id = RequestId.generate() talkreq = TalkReq( request_id=request_id, protocol=protocol, @@ -485,7 +484,7 @@ async def _send_request( self._node_addresses[dest_node_id] = dest_addr # Build and send packet. - nonce = generate_nonce() + nonce = Nonce.generate() message_bytes = encode_message(message) packet = self._build_message_packet(dest_node_id, dest_addr, nonce, message_bytes) @@ -680,7 +679,7 @@ async def _handle_whoareyou( # and our original message (encrypted). This completes the # handshake and delivers the message in one round trip. message_bytes = encode_message(pending.message) - nonce = generate_nonce() + nonce = Nonce.generate() packet = encode_packet( dest_node_id=remote_node_id, @@ -886,7 +885,7 @@ async def send_response( return False # Encode and encrypt the response. - nonce = generate_nonce() + nonce = Nonce.generate() message_bytes = encode_message(message) authdata = encode_message_authdata(self._local_node_id) diff --git a/src/lean_spec/subspecs/networking/enr/enr.py b/src/lean_spec/subspecs/networking/enr/enr.py index b0e6879c..d1fe3075 100644 --- a/src/lean_spec/subspecs/networking/enr/enr.py +++ b/src/lean_spec/subspecs/networking/enr/enr.py @@ -39,7 +39,7 @@ from __future__ import annotations import base64 -from typing import ClassVar, Self +from typing import ClassVar, Final, Self from Crypto.Hash import keccak from cryptography.exceptions import InvalidSignature @@ -54,6 +54,7 @@ from lean_spec.types import ( Bytes33, Bytes64, + RLPItem, StrictBaseModel, Uint64, rlp, @@ -63,7 +64,7 @@ from .eth2 import AttestationSubnets, Eth2Data, SyncCommitteeSubnets from .keys import EnrKey -ENR_PREFIX = "enr:" +ENR_PREFIX: Final = "enr:" """Text prefix for ENR strings.""" @@ -203,7 +204,7 @@ def is_compatible_with(self, other: ENR) -> bool: return False return self_eth2.fork_digest == other_eth2.fork_digest - def _build_content_items(self) -> list[bytes]: + def _build_content_items(self) -> list[RLPItem]: """ Build the list of content items for RLP encoding. @@ -213,7 +214,7 @@ def _build_content_items(self) -> list[bytes]: # Sequence number: minimal big-endian, empty bytes for zero. seq_bytes = self.seq.to_bytes(8, "big").lstrip(b"\x00") or b"" - items: list[bytes] = [seq_bytes] + items: list[RLPItem] = [seq_bytes] for key in sorted_keys: items.append(key.encode("utf-8")) diff --git a/src/lean_spec/subspecs/networking/enr/eth2.py b/src/lean_spec/subspecs/networking/enr/eth2.py index da835d81..8d1b0721 100644 --- a/src/lean_spec/subspecs/networking/enr/eth2.py +++ b/src/lean_spec/subspecs/networking/enr/eth2.py @@ -16,7 +16,7 @@ See: https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/p2p-interface.md """ -from typing import ClassVar +from typing import ClassVar, Final from lean_spec.subspecs.containers.validator import SubnetId from lean_spec.subspecs.networking.types import ForkDigest, Version @@ -24,7 +24,7 @@ from lean_spec.types.bitfields import BaseBitvector from lean_spec.types.boolean import Boolean -FAR_FUTURE_EPOCH = Uint64(2**64 - 1) +FAR_FUTURE_EPOCH: Final = Uint64(2**64 - 1) """Sentinel value indicating no scheduled fork.""" diff --git a/src/lean_spec/subspecs/networking/enr/keys.py b/src/lean_spec/subspecs/networking/enr/keys.py index 5444a881..45a68d7d 100644 --- a/src/lean_spec/subspecs/networking/enr/keys.py +++ b/src/lean_spec/subspecs/networking/enr/keys.py @@ -8,9 +8,11 @@ See: https://eips.ethereum.org/EIPS/eip-778 """ +from __future__ import annotations + from typing import Final -EnrKey = str +type EnrKey = str """ENR key identifier (any string/bytes per EIP-778).""" # EIP-778 Standard Keys diff --git a/src/lean_spec/subspecs/networking/gossipsub/behavior.py b/src/lean_spec/subspecs/networking/gossipsub/behavior.py index ef613220..788a0b3a 100644 --- a/src/lean_spec/subspecs/networking/gossipsub/behavior.py +++ b/src/lean_spec/subspecs/networking/gossipsub/behavior.py @@ -62,7 +62,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass, field from itertools import count -from typing import ClassVar, cast +from typing import ClassVar, Final, cast from lean_spec.subspecs.networking.config import PRUNE_BACKOFF from lean_spec.subspecs.networking.gossipsub.mcache import MessageCache, SeenCache @@ -79,8 +79,6 @@ ControlPrune, Message, SubOpts, - create_graft_rpc, - create_subscription_rpc, ) from lean_spec.subspecs.networking.gossipsub.types import MessageId from lean_spec.subspecs.networking.transport import PeerId @@ -104,7 +102,7 @@ class GossipsubMessageEvent: data: bytes """Message payload (may be compressed).""" - message_id: bytes + message_id: MessageId """Computed message ID.""" @@ -122,7 +120,7 @@ class GossipsubPeerEvent: """True if peer subscribed, False if unsubscribed.""" -IDONTWANT_SIZE_THRESHOLD: int = 1024 +IDONTWANT_SIZE_THRESHOLD: Final = 1024 """Minimum message size (bytes) to trigger IDONTWANT. Messages smaller than this are cheap to transmit and don't @@ -383,7 +381,7 @@ async def add_peer( logger.debug("Added outbound stream for peer %s", peer_id) if self.mesh.subscriptions: - rpc = create_subscription_rpc(list(self.mesh.subscriptions), subscribe=True) + rpc = RPC.subscription(list(self.mesh.subscriptions), subscribe=True) await self._send_rpc(peer_id, rpc) def has_outbound_stream(self, peer_id: PeerId) -> bool: @@ -795,7 +793,7 @@ async def _maintain_mesh(self, topic: str, now: float) -> None: for peer_id in to_graft: self.mesh.add_to_mesh(topic, peer_id) - rpc = create_graft_rpc([topic]) + rpc = RPC.graft([topic]) for peer_id in to_graft: await self._send_rpc(peer_id, rpc) @@ -954,7 +952,7 @@ async def _broadcast_subscription( subscribe: True for subscribe, False for unsubscribe. prune_peers: Former mesh peers to PRUNE (unsubscribe only). """ - rpc = create_subscription_rpc([topic], subscribe) + rpc = RPC.subscription([topic], subscribe) for peer_id, state in self._peers.items(): if state.outbound_stream is not None: await self._send_rpc(peer_id, rpc) @@ -976,7 +974,7 @@ async def _broadcast_subscription( ][:needed] if eligible: - graft_rpc = create_graft_rpc([topic]) + graft_rpc = RPC.graft([topic]) for peer_id in eligible: self.mesh.add_to_mesh(topic, peer_id) await self._send_rpc(peer_id, graft_rpc) diff --git a/src/lean_spec/subspecs/networking/gossipsub/message.py b/src/lean_spec/subspecs/networking/gossipsub/message.py index 2037432d..c8b61ccb 100644 --- a/src/lean_spec/subspecs/networking/gossipsub/message.py +++ b/src/lean_spec/subspecs/networking/gossipsub/message.py @@ -65,7 +65,7 @@ from .types import MessageId -SnappyDecompressor = Callable[[bytes], bytes] +type SnappyDecompressor = Callable[[bytes], bytes] """Callable that decompresses snappy-compressed data. Should raise an exception if decompression fails. diff --git a/src/lean_spec/subspecs/networking/gossipsub/rpc.py b/src/lean_spec/subspecs/networking/gossipsub/rpc.py index 83b7a7fa..3c7a58d3 100644 --- a/src/lean_spec/subspecs/networking/gossipsub/rpc.py +++ b/src/lean_spec/subspecs/networking/gossipsub/rpc.py @@ -41,19 +41,20 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import Final from lean_spec.subspecs.networking.varint import decode_varint, encode_varint -WIRE_TYPE_VARINT = 0 +WIRE_TYPE_VARINT: Final = 0 """Varint wire type for int32, int64, uint32, uint64, sint32, sint64, bool, enum.""" -WIRE_TYPE_64BIT = 1 +WIRE_TYPE_64BIT: Final = 1 """64-bit wire type for fixed64, sfixed64, double.""" -WIRE_TYPE_LENGTH_DELIMITED = 2 +WIRE_TYPE_LENGTH_DELIMITED: Final = 2 """Length-delimited wire type for string, bytes, embedded messages, packed repeated fields.""" -WIRE_TYPE_32BIT = 5 +WIRE_TYPE_32BIT: Final = 5 """32-bit wire type for fixed32, sfixed32, float.""" @@ -620,6 +621,27 @@ def is_empty(self) -> bool: and (self.control is None or self.control.is_empty()) ) + @classmethod + def subscription(cls, topics: list[str], subscribe: bool = True) -> RPC: + """ + Create an RPC with subscription messages. + + Args: + topics: List of topic IDs to subscribe/unsubscribe. + subscribe: True to subscribe, False to unsubscribe. + """ + return cls(subscriptions=[SubOpts(subscribe=subscribe, topic_id=t) for t in topics]) + + @classmethod + def graft(cls, topics: list[str]) -> RPC: + """ + Create an RPC with GRAFT control messages. + + Args: + topics: List of topic IDs to request mesh membership for. + """ + return cls(control=ControlMessage(graft=[ControlGraft(topic_id=t) for t in topics])) + def _skip_field(data: bytes, pos: int, wire_type: int) -> int: """Skip an unknown field based on wire type. @@ -640,30 +662,3 @@ def _skip_field(data: bytes, pos: int, wire_type: int) -> int: else: raise ProtobufDecodeError(f"Unknown wire type: {wire_type}") return pos - - -def create_subscription_rpc(topics: list[str], subscribe: bool = True) -> RPC: - """ - Create an RPC with subscription messages. - - Args: - topics: List of topic IDs to subscribe/unsubscribe. - subscribe: True to subscribe, False to unsubscribe. - - Returns: - RPC ready to be encoded and sent. - """ - return RPC(subscriptions=[SubOpts(subscribe=subscribe, topic_id=t) for t in topics]) - - -def create_graft_rpc(topics: list[str]) -> RPC: - """ - Create an RPC with GRAFT control messages. - - Args: - topics: List of topic IDs to request mesh membership for. - - Returns: - RPC ready to be encoded and sent. - """ - return RPC(control=ControlMessage(graft=[ControlGraft(topic_id=t) for t in topics])) diff --git a/src/lean_spec/subspecs/networking/gossipsub/topic.py b/src/lean_spec/subspecs/networking/gossipsub/topic.py index 32b6a866..05c06626 100644 --- a/src/lean_spec/subspecs/networking/gossipsub/topic.py +++ b/src/lean_spec/subspecs/networking/gossipsub/topic.py @@ -60,6 +60,7 @@ from dataclasses import dataclass from enum import Enum +from typing import Final from lean_spec.subspecs.containers.validator import SubnetId @@ -74,34 +75,34 @@ def __init__(self, expected: str, actual: str) -> None: super().__init__(f"Fork mismatch: expected {expected}, got {actual}") -TOPIC_PREFIX: str = "leanconsensus" +TOPIC_PREFIX: Final = "leanconsensus" """Network prefix for Lean consensus gossip topics. Identifies this network in topic strings. Different networks (mainnet, testnets) may use different prefixes. """ -ENCODING_POSTFIX: str = "ssz_snappy" +ENCODING_POSTFIX: Final = "ssz_snappy" """Encoding suffix for SSZ with Snappy compression. All Ethereum consensus gossip messages use SSZ serialization with Snappy compression. """ -BLOCK_TOPIC_NAME: str = "block" +BLOCK_TOPIC_NAME: Final = "block" """Topic name for block messages. Used in the topic string to identify signed beacon block messages. """ -ATTESTATION_SUBNET_TOPIC_PREFIX: str = "attestation" +ATTESTATION_SUBNET_TOPIC_PREFIX: Final = "attestation" """Base prefix for attestation subnet topic names. Full topic names are formatted as "attestation_{subnet_id}". """ -AGGREGATED_ATTESTATION_TOPIC_NAME: str = "aggregation" +AGGREGATED_ATTESTATION_TOPIC_NAME: Final = "aggregation" """Topic name for committee aggregation messages. Used in the topic string to identify committee's aggregation messages. diff --git a/src/lean_spec/subspecs/networking/reqresp/handler.py b/src/lean_spec/subspecs/networking/reqresp/handler.py index beee6265..6f5e1134 100644 --- a/src/lean_spec/subspecs/networking/reqresp/handler.py +++ b/src/lean_spec/subspecs/networking/reqresp/handler.py @@ -62,6 +62,7 @@ import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass +from typing import Final from lean_spec.snappy import SnappyDecompressionError, frame_decompress from lean_spec.subspecs.containers import SignedBlockWithAttestation @@ -116,7 +117,7 @@ async def finish(self) -> None: await self._stream.close() -BlockLookup = Callable[[Bytes32], Awaitable[SignedBlockWithAttestation | None]] +type BlockLookup = Callable[[Bytes32], Awaitable[SignedBlockWithAttestation | None]] """Type alias for block lookup function. Takes a block root and returns the block if available, None otherwise. @@ -220,7 +221,7 @@ async def handle_blocks_by_root( logger.warning("Error looking up block %s: %s", root.hex()[:8], e) -REQRESP_PROTOCOL_IDS: frozenset[str] = frozenset( +REQRESP_PROTOCOL_IDS: Final[frozenset[str]] = frozenset( { STATUS_PROTOCOL_V1, BLOCKS_BY_ROOT_PROTOCOL_V1, diff --git a/src/lean_spec/subspecs/networking/reqresp/message.py b/src/lean_spec/subspecs/networking/reqresp/message.py index fc5b04c2..d24ce330 100644 --- a/src/lean_spec/subspecs/networking/reqresp/message.py +++ b/src/lean_spec/subspecs/networking/reqresp/message.py @@ -5,7 +5,7 @@ domain. All messages are SSZ-encoded and then compressed with Snappy frames. """ -from typing import ClassVar +from typing import ClassVar, Final from lean_spec.subspecs.containers import Checkpoint from lean_spec.types import Bytes32, SSZList @@ -14,7 +14,7 @@ from ..config import MAX_REQUEST_BLOCKS from ..types import ProtocolId -STATUS_PROTOCOL_V1: ProtocolId = "/leanconsensus/req/status/1/ssz_snappy" +STATUS_PROTOCOL_V1: Final[ProtocolId] = "/leanconsensus/req/status/1/ssz_snappy" """The protocol ID for the Status v1 request/response message.""" @@ -40,7 +40,7 @@ class Status(Container): """The client's current head checkpoint.""" -BLOCKS_BY_ROOT_PROTOCOL_V1: ProtocolId = "/leanconsensus/req/blocks_by_root/1/ssz_snappy" +BLOCKS_BY_ROOT_PROTOCOL_V1: Final[ProtocolId] = "/leanconsensus/req/blocks_by_root/1/ssz_snappy" """The protocol ID for the BlocksByRoot v1 request/response message.""" diff --git a/src/lean_spec/subspecs/networking/service/events.py b/src/lean_spec/subspecs/networking/service/events.py index 9095cfca..d5bdcd80 100644 --- a/src/lean_spec/subspecs/networking/service/events.py +++ b/src/lean_spec/subspecs/networking/service/events.py @@ -129,7 +129,7 @@ class PeerDisconnectedEvent: """Peer that disconnected.""" -NetworkEvent = ( +type NetworkEvent = ( GossipBlockEvent | GossipAttestationEvent | GossipAggregatedAttestationEvent diff --git a/src/lean_spec/subspecs/networking/transport/quic/tls.py b/src/lean_spec/subspecs/networking/transport/quic/tls.py index a594376f..f1bdee24 100644 --- a/src/lean_spec/subspecs/networking/transport/quic/tls.py +++ b/src/lean_spec/subspecs/networking/transport/quic/tls.py @@ -24,7 +24,7 @@ import hashlib from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization @@ -33,10 +33,10 @@ if TYPE_CHECKING: from ..identity import IdentityKeypair -LIBP2P_EXTENSION_OID = x509.ObjectIdentifier("1.3.6.1.4.1.53594.1.1") +LIBP2P_EXTENSION_OID: Final = x509.ObjectIdentifier("1.3.6.1.4.1.53594.1.1") """libp2p TLS extension OID (Protocol Labs assigned).""" -SIGNATURE_PREFIX = b"libp2p-tls-handshake:" +SIGNATURE_PREFIX: Final = b"libp2p-tls-handshake:" """ Prefix for the signed payload. @@ -45,7 +45,7 @@ """ # Key type identifiers matching libp2p protobuf definitions -KEY_TYPE_SECP256K1 = 2 +KEY_TYPE_SECP256K1: Final = 2 """secp256k1 key type in libp2p protobuf.""" diff --git a/src/lean_spec/subspecs/networking/types.py b/src/lean_spec/subspecs/networking/types.py index 208b5367..82ce18af 100644 --- a/src/lean_spec/subspecs/networking/types.py +++ b/src/lean_spec/subspecs/networking/types.py @@ -1,5 +1,7 @@ """Networking Types""" +from __future__ import annotations + from enum import IntEnum, auto from lean_spec.types import Uint64 @@ -32,10 +34,10 @@ class SeqNumber(Uint64): """Sequence number used in ENR records, metadata, and ping messages.""" -ProtocolId = str +type ProtocolId = str """Libp2p protocol identifier, e.g. ``/eth2/beacon_chain/req/status/1/ssz_snappy``.""" -Multiaddr = str +type Multiaddr = str """Multiaddress string, e.g. ``/ip4/192.168.1.1/udp/9000/quic-v1``.""" diff --git a/src/lean_spec/subspecs/node/node.py b/src/lean_spec/subspecs/node/node.py index 7466fb6e..6bddc0c8 100644 --- a/src/lean_spec/subspecs/node/node.py +++ b/src/lean_spec/subspecs/node/node.py @@ -16,6 +16,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path +from typing import Final from lean_spec.subspecs.api import ApiServer, ApiServerConfig from lean_spec.subspecs.chain import SlotClock @@ -41,7 +42,7 @@ from lean_spec.subspecs.validator import ValidatorRegistry, ValidatorService from lean_spec.types import Bytes32, Uint64 -_ZERO_TIME = Uint64(0) +_ZERO_TIME: Final = Uint64(0) """Default genesis time for database loading when no genesis time is available.""" diff --git a/src/lean_spec/subspecs/poseidon2/constants.py b/src/lean_spec/subspecs/poseidon2/constants.py index e31c183d..bc567662 100644 --- a/src/lean_spec/subspecs/poseidon2/constants.py +++ b/src/lean_spec/subspecs/poseidon2/constants.py @@ -1,5 +1,7 @@ """Round constants for the Poseidon2 permutation over the KoalaBear field.""" +from __future__ import annotations + from ..koalabear.field import Fp # For width 16: 64 external_initial + 20 internal + 64 external_final = 148 constants diff --git a/src/lean_spec/subspecs/poseidon2/permutation.py b/src/lean_spec/subspecs/poseidon2/permutation.py index bc8a7970..54aa05cb 100644 --- a/src/lean_spec/subspecs/poseidon2/permutation.py +++ b/src/lean_spec/subspecs/poseidon2/permutation.py @@ -7,6 +7,8 @@ Uses numpy arrays for vectorized field operations. """ +from __future__ import annotations + from typing import Final, Self import numpy as np diff --git a/src/lean_spec/subspecs/ssz/constants.py b/src/lean_spec/subspecs/ssz/constants.py index a4abd3ea..10dff256 100644 --- a/src/lean_spec/subspecs/ssz/constants.py +++ b/src/lean_spec/subspecs/ssz/constants.py @@ -1,10 +1,14 @@ """Constants defined in the SSZ specification.""" -BYTES_PER_CHUNK: int = 32 +from __future__ import annotations + +from typing import Final + +BYTES_PER_CHUNK: Final = 32 """Number of bytes per Merkle chunk.""" -BITS_PER_BYTE: int = 8 +BITS_PER_BYTE: Final = 8 """Number of bits per byte.""" -BITS_PER_CHUNK: int = BYTES_PER_CHUNK * BITS_PER_BYTE +BITS_PER_CHUNK: Final = BYTES_PER_CHUNK * BITS_PER_BYTE """Number of bits per Merkle chunk (256 bits).""" diff --git a/src/lean_spec/subspecs/ssz/merkleization.py b/src/lean_spec/subspecs/ssz/merkleization.py index 53eb6b91..f28ce354 100644 --- a/src/lean_spec/subspecs/ssz/merkleization.py +++ b/src/lean_spec/subspecs/ssz/merkleization.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Sequence +from collections.abc import Sequence from lean_spec.subspecs.ssz.utils import get_power_of_two_ceil, hash_nodes from lean_spec.types import ZERO_HASH @@ -11,7 +11,17 @@ _MAX_ZERO_HASH_DEPTH: int = 64 """Maximum depth of pre-computed zero hashes (supports trees up to 2^64 leaves).""" -_ZERO_HASHES: list[Bytes32] = [] + +def _precompute_zero_hashes() -> tuple[Bytes32, ...]: + """Pre-compute zero hashes at module load time for O(1) lookup.""" + hashes: list[Bytes32] = [ZERO_HASH] + for _ in range(_MAX_ZERO_HASH_DEPTH): + prev = hashes[-1] + hashes.append(hash_nodes(prev, prev)) + return tuple(hashes) + + +_ZERO_HASHES: tuple[Bytes32, ...] = _precompute_zero_hashes() """Pre-computed zero hash roots at each depth level. Index i contains the root of a full zero tree with 2^i leaves: @@ -23,18 +33,6 @@ """ -def _precompute_zero_hashes() -> None: - """Pre-compute zero hashes at module load time for O(1) lookup.""" - global _ZERO_HASHES - _ZERO_HASHES = [ZERO_HASH] - for _ in range(_MAX_ZERO_HASH_DEPTH): - prev = _ZERO_HASHES[-1] - _ZERO_HASHES.append(hash_nodes(prev, prev)) - - -_precompute_zero_hashes() - - def _zero_tree_root(width_pow2: int) -> Bytes32: """Return the Merkle root of a full zero tree with `width_pow2` leaves. diff --git a/src/lean_spec/subspecs/ssz/pack.py b/src/lean_spec/subspecs/ssz/pack.py index edcb7f10..f58968b2 100644 --- a/src/lean_spec/subspecs/ssz/pack.py +++ b/src/lean_spec/subspecs/ssz/pack.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Sequence +from collections.abc import Sequence from lean_spec.subspecs.ssz.constants import BITS_PER_BYTE, BYTES_PER_CHUNK from lean_spec.types.byte_arrays import Bytes32 diff --git a/src/lean_spec/subspecs/ssz/utils.py b/src/lean_spec/subspecs/ssz/utils.py index 9a3f329a..4f824f2c 100644 --- a/src/lean_spec/subspecs/ssz/utils.py +++ b/src/lean_spec/subspecs/ssz/utils.py @@ -1,5 +1,7 @@ """Generic helper functions for SSZ and Merkle proofs.""" +from __future__ import annotations + import hashlib from lean_spec.types.byte_arrays import Bytes32 diff --git a/src/lean_spec/subspecs/storage/namespaces.py b/src/lean_spec/subspecs/storage/namespaces.py index 608c0587..93e940df 100644 --- a/src/lean_spec/subspecs/storage/namespaces.py +++ b/src/lean_spec/subspecs/storage/namespaces.py @@ -8,6 +8,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Final @dataclass(frozen=True, slots=True) @@ -137,8 +138,8 @@ class SlotIndexNamespace: # Singleton instances for convenient access -BLOCKS = BlockNamespace() -STATES = StateNamespace() -CHECKPOINTS = CheckpointNamespace() -ATTESTATIONS = AttestationNamespace() -SLOT_INDEX = SlotIndexNamespace() +BLOCKS: Final = BlockNamespace() +STATES: Final = StateNamespace() +CHECKPOINTS: Final = CheckpointNamespace() +ATTESTATIONS: Final = AttestationNamespace() +SLOT_INDEX: Final = SlotIndexNamespace() diff --git a/src/lean_spec/subspecs/sync/checkpoint_sync.py b/src/lean_spec/subspecs/sync/checkpoint_sync.py index 18fa9b26..4005a52b 100644 --- a/src/lean_spec/subspecs/sync/checkpoint_sync.py +++ b/src/lean_spec/subspecs/sync/checkpoint_sync.py @@ -19,6 +19,7 @@ from __future__ import annotations import logging +from typing import Final import httpx @@ -28,10 +29,10 @@ logger = logging.getLogger(__name__) -DEFAULT_TIMEOUT = 60.0 +DEFAULT_TIMEOUT: Final = 60.0 """HTTP request timeout in seconds. Large states may take time to transfer.""" -FINALIZED_STATE_ENDPOINT = "/lean/v0/states/finalized" +FINALIZED_STATE_ENDPOINT: Final = "/lean/v0/states/finalized" """API endpoint for fetching finalized state. Follows Beacon API conventions.""" diff --git a/src/lean_spec/subspecs/sync/peer_manager.py b/src/lean_spec/subspecs/sync/peer_manager.py index 0f4b9b8d..f334003c 100644 --- a/src/lean_spec/subspecs/sync/peer_manager.py +++ b/src/lean_spec/subspecs/sync/peer_manager.py @@ -9,6 +9,7 @@ import random from collections import Counter from dataclasses import dataclass, field +from typing import Final from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.networking import PeerId @@ -17,19 +18,19 @@ from .config import MAX_CONCURRENT_REQUESTS -INITIAL_PEER_SCORE: int = 100 +INITIAL_PEER_SCORE: Final = 100 """Starting score for newly added peers.""" -MIN_PEER_SCORE: int = 0 +MIN_PEER_SCORE: Final = 0 """Minimum peer score (floor).""" -MAX_PEER_SCORE: int = 200 +MAX_PEER_SCORE: Final = 200 """Maximum peer score (ceiling).""" -SCORE_SUCCESS_BONUS: int = 10 +SCORE_SUCCESS_BONUS: Final = 10 """Score increase for a successful request.""" -SCORE_FAILURE_PENALTY: int = 20 +SCORE_FAILURE_PENALTY: Final = 20 """Score decrease for a failed request.""" diff --git a/src/lean_spec/subspecs/validator/registry.py b/src/lean_spec/subspecs/validator/registry.py index b401f23c..7427ca45 100644 --- a/src/lean_spec/subspecs/validator/registry.py +++ b/src/lean_spec/subspecs/validator/registry.py @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) -NodeValidatorMapping = dict[str, list[int]] +type NodeValidatorMapping = dict[str, list[int]] """Mapping from node identifier to list of validator indices.""" diff --git a/src/lean_spec/subspecs/validator/service.py b/src/lean_spec/subspecs/validator/service.py index 6333e36b..ed13c114 100644 --- a/src/lean_spec/subspecs/validator/service.py +++ b/src/lean_spec/subspecs/validator/service.py @@ -64,9 +64,9 @@ logger = logging.getLogger(__name__) -BlockPublisher = Callable[[SignedBlockWithAttestation], Awaitable[None]] +type BlockPublisher = Callable[[SignedBlockWithAttestation], Awaitable[None]] """Callback for publishing signed blocks with proposer attestations.""" -AttestationPublisher = Callable[[SignedAttestation], Awaitable[None]] +type AttestationPublisher = Callable[[SignedAttestation], Awaitable[None]] """Callback for publishing produced attestations.""" diff --git a/src/lean_spec/subspecs/xmss/aggregation.py b/src/lean_spec/subspecs/xmss/aggregation.py index 809a52e7..c1995be1 100644 --- a/src/lean_spec/subspecs/xmss/aggregation.py +++ b/src/lean_spec/subspecs/xmss/aggregation.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass -from typing import Self, Sequence +from typing import Self from lean_multisig_py import ( aggregate_signatures, @@ -12,6 +13,7 @@ verify_aggregated_signatures, ) +from lean_spec.config import LEAN_ENV from lean_spec.subspecs.containers.attestation import AggregationBits from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.containers.validator import ValidatorIndex @@ -19,7 +21,6 @@ from lean_spec.types.byte_arrays import ByteListMiB from lean_spec.types.container import Container -from .constants import LEAN_ENV from .containers import PublicKey, Signature diff --git a/src/lean_spec/subspecs/xmss/constants.py b/src/lean_spec/subspecs/xmss/constants.py index 38d5c4f5..3c391827 100644 --- a/src/lean_spec/subspecs/xmss/constants.py +++ b/src/lean_spec/subspecs/xmss/constants.py @@ -9,6 +9,8 @@ We also provide a test instantiation for testing purposes. """ +from __future__ import annotations + from typing import Final from lean_spec.config import LEAN_ENV @@ -163,7 +165,7 @@ def SIGNATURE_LEN_BYTES(self) -> int: # noqa: N802 TWEAK_PREFIX_MESSAGE: Final = Fp(value=0x02) """The unique prefix for tweaks used in the initial message hashing step.""" -PRF_KEY_LENGTH: int = 32 +PRF_KEY_LENGTH: Final = 32 """The length of the PRF secret key in bytes.""" _LEAN_ENV_TO_CONFIG = { diff --git a/src/lean_spec/subspecs/xmss/containers.py b/src/lean_spec/subspecs/xmss/containers.py index e7a5a795..ddf06b10 100644 --- a/src/lean_spec/subspecs/xmss/containers.py +++ b/src/lean_spec/subspecs/xmss/containers.py @@ -7,7 +7,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Mapping, NamedTuple +from collections.abc import Mapping +from typing import TYPE_CHECKING, NamedTuple from pydantic import model_serializer diff --git a/src/lean_spec/subspecs/xmss/hypercube.py b/src/lean_spec/subspecs/xmss/hypercube.py index 32762656..8a156901 100644 --- a/src/lean_spec/subspecs/xmss/hypercube.py +++ b/src/lean_spec/subspecs/xmss/hypercube.py @@ -36,8 +36,9 @@ from dataclasses import dataclass from functools import lru_cache from itertools import accumulate +from typing import Final -MAX_DIMENSION = 100 +MAX_DIMENSION: Final = 100 """The maximum dimension `v` for which layer sizes will be precomputed.""" diff --git a/src/lean_spec/subspecs/xmss/poseidon.py b/src/lean_spec/subspecs/xmss/poseidon.py index 312798b1..cbe0ccf0 100644 --- a/src/lean_spec/subspecs/xmss/poseidon.py +++ b/src/lean_spec/subspecs/xmss/poseidon.py @@ -83,7 +83,8 @@ def compress(self, input_vec: list[Fp], width: int, output_len: int) -> list[Fp] raise ValueError("Input vector is too short for requested output length.") # Select the correct permutation parameters based on the state width. - assert width in (16, 24), "Width must be 16 or 24" + if width not in (16, 24): + raise ValueError(f"Width must be 16 or 24, got {width}") params = self.params16 if width == 16 else self.params24 # Create a padded input by extending with zeros to match the state width. @@ -171,7 +172,8 @@ def sponge( raise ValueError("Capacity length must be smaller than the state width.") # Determine the permutation parameters and the size of the rate. - assert width in (16, 24), "Width must be 16 or 24" + if width not in (16, 24): + raise ValueError(f"Width must be 16 or 24, got {width}") params = self.params16 if width == 16 else self.params24 rate = width - len(capacity_value) diff --git a/src/lean_spec/subspecs/xmss/prf.py b/src/lean_spec/subspecs/xmss/prf.py index 8b9a8c64..83d6c2ea 100644 --- a/src/lean_spec/subspecs/xmss/prf.py +++ b/src/lean_spec/subspecs/xmss/prf.py @@ -11,6 +11,7 @@ import hashlib import os +from typing import Final from pydantic import model_validator @@ -26,26 +27,7 @@ ) from .types import HashDigestVector, PRFKey, Randomness -PRF_DOMAIN_SEP: bytes = bytes( - [ - 0xAE, - 0xAE, - 0x22, - 0xFF, - 0x00, - 0x01, - 0xFA, - 0xFF, - 0x21, - 0xAF, - 0x12, - 0x00, - 0x01, - 0x11, - 0xFF, - 0x00, - ] -) +PRF_DOMAIN_SEP: Final[bytes] = b"\xae\xae\x22\xff\x00\x01\xfa\xff\x21\xaf\x12\x00\x01\x11\xff\x00" """ A 16-byte domain separator to ensure PRF outputs are unique to this context. @@ -53,7 +35,7 @@ (SHAKE128) were used for other purposes in the system. """ -PRF_DOMAIN_SEP_DOMAIN_ELEMENT: bytes = bytes([0x00]) +PRF_DOMAIN_SEP_DOMAIN_ELEMENT: Final[bytes] = b"\x00" """ A 1-byte domain separator for deriving domain elements (used in `apply`). @@ -61,7 +43,7 @@ from the PRF calls for generating randomness during signing. """ -PRF_DOMAIN_SEP_RANDOMNESS: bytes = bytes([0x01]) +PRF_DOMAIN_SEP_RANDOMNESS: Final[bytes] = b"\x01" """ A 1-byte domain separator for deriving randomness (used in `get_randomness`). @@ -70,7 +52,7 @@ between the two use cases. """ -PRF_BYTES_PER_FE: int = 8 +PRF_BYTES_PER_FE: Final[int] = 8 """ The number of bytes of SHAKE128 output used to generate one field element. diff --git a/src/lean_spec/subspecs/xmss/rand.py b/src/lean_spec/subspecs/xmss/rand.py index c108b165..97421899 100644 --- a/src/lean_spec/subspecs/xmss/rand.py +++ b/src/lean_spec/subspecs/xmss/rand.py @@ -1,5 +1,7 @@ """Random data generator for the XMSS signature scheme.""" +from __future__ import annotations + import secrets from pydantic import model_validator @@ -19,7 +21,7 @@ class Rand(StrictBaseModel): """Configuration parameters for the random generator.""" @model_validator(mode="after") - def _validate_strict_types(self) -> "Rand": + def _validate_strict_types(self) -> Rand: """Reject subclasses to prevent type confusion attacks.""" enforce_strict_types(self, config=XmssConfig) return self diff --git a/src/lean_spec/subspecs/xmss/target_sum.py b/src/lean_spec/subspecs/xmss/target_sum.py index e0393a8c..b6a80f77 100644 --- a/src/lean_spec/subspecs/xmss/target_sum.py +++ b/src/lean_spec/subspecs/xmss/target_sum.py @@ -6,6 +6,8 @@ top of the message hash output. """ +from __future__ import annotations + from pydantic import model_validator from lean_spec.types import Bytes32, StrictBaseModel, Uint64 @@ -35,7 +37,7 @@ class TargetSumEncoder(StrictBaseModel): """Message hasher for encoding.""" @model_validator(mode="after") - def _validate_strict_types(self) -> "TargetSumEncoder": + def _validate_strict_types(self) -> TargetSumEncoder: """Reject subclasses to prevent type confusion attacks.""" enforce_strict_types(self, config=XmssConfig, message_hasher=MessageHasher) return self @@ -79,13 +81,10 @@ def encode( if sum(codeword_candidate) == self.config.TARGET_SUM: # If the sum is correct, this is a valid codeword for the one-time signature. return codeword_candidate - else: - # If the sum does not match, this `rho` is invalid for - # this message. - # - # The caller (the `sign` function) will need to try again with new - # randomness. - return None + + # If the sum does not match, this `rho` is invalid for this message. + # The caller will need to try again with new randomness. + return None PROD_TARGET_SUM_ENCODER = TargetSumEncoder(config=PROD_CONFIG, message_hasher=PROD_MESSAGE_HASHER) diff --git a/src/lean_spec/subspecs/xmss/types.py b/src/lean_spec/subspecs/xmss/types.py index e2c4d78a..99ac8178 100644 --- a/src/lean_spec/subspecs/xmss/types.py +++ b/src/lean_spec/subspecs/xmss/types.py @@ -1,5 +1,7 @@ """Base types for the XMSS signature scheme.""" +from typing import Final + from lean_spec.subspecs.koalabear import Fp from ...types import Uint64 @@ -20,7 +22,7 @@ class PRFKey(BaseBytes): LENGTH = PRF_KEY_LENGTH -HASH_DIGEST_LENGTH = TARGET_CONFIG.HASH_LEN_FE +HASH_DIGEST_LENGTH: Final = TARGET_CONFIG.HASH_LEN_FE """ The fixed length of a hash digest in field elements. @@ -32,7 +34,7 @@ class PRFKey(BaseBytes): # - A bottom tree has at most 2^(LOG_LIFETIME/2) leaves # - With padding, we may add up to 2 additional nodes # - To be generous and future-proof, we use 2^(LOG_LIFETIME/2 + 1) -NODE_LIST_LIMIT = 1 << (TARGET_CONFIG.LOG_LIFETIME // 2 + 1) +NODE_LIST_LIMIT: Final = 1 << (TARGET_CONFIG.LOG_LIFETIME // 2 + 1) """ The maximum number of nodes that can be stored in a sparse Merkle tree layer. @@ -125,7 +127,7 @@ class HashTreeLayer(Container): """SSZ-compliant list of hash digests stored for this layer.""" -LAYERS_LIMIT = TARGET_CONFIG.LOG_LIFETIME + 1 +LAYERS_LIMIT: Final = TARGET_CONFIG.LOG_LIFETIME + 1 """ The maximum number of layers in a subtree. diff --git a/src/lean_spec/subspecs/xmss/utils.py b/src/lean_spec/subspecs/xmss/utils.py index cdde32ed..623c3b76 100644 --- a/src/lean_spec/subspecs/xmss/utils.py +++ b/src/lean_spec/subspecs/xmss/utils.py @@ -1,5 +1,7 @@ """Utility functions for the XMSS signature scheme.""" +from __future__ import annotations + from ...types.uint import Uint64 from ..koalabear import Fp, P from .rand import Rand diff --git a/src/lean_spec/types/base.py b/src/lean_spec/types/base.py index 2fde9d90..6980e64e 100644 --- a/src/lean_spec/types/base.py +++ b/src/lean_spec/types/base.py @@ -1,5 +1,7 @@ """Reusable, strict base models for the specification.""" +from __future__ import annotations + from typing import Any from pydantic import BaseModel, ConfigDict diff --git a/src/lean_spec/types/bitfields.py b/src/lean_spec/types/bitfields.py index 7a687d56..67b0fa03 100644 --- a/src/lean_spec/types/bitfields.py +++ b/src/lean_spec/types/bitfields.py @@ -13,12 +13,12 @@ from __future__ import annotations +from collections.abc import Sequence from typing import ( IO, Any, ClassVar, Self, - Sequence, overload, ) diff --git a/src/lean_spec/types/boolean.py b/src/lean_spec/types/boolean.py index 4043bd3a..a4401da6 100644 --- a/src/lean_spec/types/boolean.py +++ b/src/lean_spec/types/boolean.py @@ -31,8 +31,8 @@ def __new__(cls, value: bool | int) -> Self: Accepts only `True`, `False`, `1`, or `0`. Raises: - SSZTypeCoercionError: If `value` is not a bool or int. - SSZDecodeError: If `value` is an integer other than 0 or 1. + SSZTypeError: If `value` is not a bool or int. + SSZValueError: If `value` is an integer other than 0 or 1. """ if not isinstance(value, int): raise SSZTypeError(f"Expected bool or int, got {type(value).__name__}") diff --git a/src/lean_spec/types/byte_arrays.py b/src/lean_spec/types/byte_arrays.py index be887c1d..e4ff022b 100644 --- a/src/lean_spec/types/byte_arrays.py +++ b/src/lean_spec/types/byte_arrays.py @@ -9,7 +9,8 @@ from __future__ import annotations -from typing import IO, Any, ClassVar, Iterable, Self, SupportsIndex +from collections.abc import Iterable +from typing import IO, Any, ClassVar, Self, SupportsIndex from pydantic import Field, field_serializer, field_validator from pydantic.annotated_handlers import GetCoreSchemaHandler @@ -19,29 +20,6 @@ from .ssz_base import SSZModel, SSZType -def _coerce_to_bytes(value: Any) -> bytes: - """ - Coerce a variety of inputs to raw bytes. - - Accepts: - - `bytes` / `bytearray` (returned as immutable `bytes`) - - Iterables of integers in [0, 255] - - Hex strings, with or without a '0x' prefix (e.g. "0xdeadbeef" or "deadbeef") - - Raises: - ValueError / TypeError if conversion is not possible or out-of-range. - """ - if isinstance(value, (bytes, bytearray)): - return bytes(value) - if isinstance(value, str): - # bytes.fromhex handles empty string and validates hex characters - return bytes.fromhex(value.removeprefix("0x")) - if isinstance(value, Iterable): - # bytes(bytearray(iterable)) enforces each element is an int in 0..255 - return bytes(bytearray(value)) - raise TypeError(f"Cannot coerce {type(value).__name__} to bytes") - - class BaseBytes(bytes, SSZType): """ A base class for fixed-length byte types that inherits from `bytes`. @@ -57,6 +35,27 @@ class BaseBytes(bytes, SSZType): LENGTH: ClassVar[int] """The exact number of bytes (overridden by subclasses).""" + @staticmethod + def _coerce_to_bytes(value: Any) -> bytes: + """ + Coerce a variety of inputs to raw bytes. + + Accepts: + - `bytes` / `bytearray` (returned as immutable `bytes`) + - Iterables of integers in [0, 255] + - Hex strings, with or without a '0x' prefix (e.g. "0xdeadbeef" or "deadbeef") + + Raises: + ValueError / TypeError if conversion is not possible or out-of-range. + """ + if isinstance(value, (bytes, bytearray)): + return bytes(value) + if isinstance(value, str): + return bytes.fromhex(value.removeprefix("0x")) + if isinstance(value, Iterable): + return bytes(bytearray(value)) + raise TypeError(f"Cannot coerce {type(value).__name__} to bytes") + def __new__(cls, value: Any = b"") -> Self: """ Create and validate a new Bytes instance. @@ -65,13 +64,13 @@ def __new__(cls, value: Any = b"") -> Self: value: Any value coercible to bytes (see `_coerce_to_bytes`). Raises: - SSZTypeDefinitionError: If the class doesn't define LENGTH. - SSZLengthError: If the resulting byte length differs from `LENGTH`. + SSZTypeError: If the class doesn't define LENGTH. + SSZValueError: If the resulting byte length differs from `LENGTH`. """ if not hasattr(cls, "LENGTH"): raise SSZTypeError(f"{cls.__name__} must define LENGTH") - b = _coerce_to_bytes(value) + b = cls._coerce_to_bytes(value) if len(b) != cls.LENGTH: raise SSZValueError(f"{cls.__name__} requires exactly {cls.LENGTH} bytes, got {len(b)}") return super().__new__(cls, b) @@ -114,8 +113,7 @@ def deserialize(cls, stream: IO[bytes], scope: int) -> Self: For a fixed-size type, `scope` must match `LENGTH`. Raises: - SSZDecodeError: if `scope` != `LENGTH`. - SSZStreamError: if the stream ends prematurely. + SSZSerializationError: if `scope` != `LENGTH` or the stream ends prematurely. """ if scope != cls.LENGTH: raise SSZSerializationError(f"{cls.__name__}: expected {cls.LENGTH} bytes, got {scope}") @@ -278,7 +276,7 @@ def _validate_byte_list_data(cls, v: Any) -> bytes: if not hasattr(cls, "LIMIT"): raise SSZTypeError(f"{cls.__name__} must define LIMIT") - b = _coerce_to_bytes(v) + b = BaseBytes._coerce_to_bytes(v) if len(b) > cls.LIMIT: raise SSZValueError(f"{cls.__name__} exceeds limit of {cls.LIMIT}, got {len(b)}") return b @@ -317,9 +315,8 @@ def deserialize(cls, stream: IO[bytes], scope: int) -> Self: knows how many bytes belong to this value in its context). Raises: - SSZDecodeError: if the scope is negative. - SSZLengthError: if the decoded length exceeds `LIMIT`. - SSZStreamError: if the stream ends prematurely. + SSZSerializationError: if the scope is negative or the stream ends prematurely. + SSZValueError: if the decoded length exceeds `LIMIT`. """ if scope < 0: raise SSZSerializationError(f"{cls.__name__}: negative scope") diff --git a/src/lean_spec/types/collections.py b/src/lean_spec/types/collections.py index 37607b45..2b9c86e1 100644 --- a/src/lean_spec/types/collections.py +++ b/src/lean_spec/types/collections.py @@ -3,14 +3,13 @@ from __future__ import annotations import io +from collections.abc import Iterator, Sequence from typing import ( IO, Any, ClassVar, Generic, - Iterator, Self, - Sequence, TypeVar, cast, overload, @@ -214,18 +213,6 @@ def deserialize(cls, stream: IO[bytes], scope: int) -> Self: elements.append(cls.ELEMENT_TYPE.deserialize(stream, end - start)) return cls(data=elements) - def encode_bytes(self) -> bytes: - """Serializes the SSZVector to a byte string.""" - with io.BytesIO() as stream: - self.serialize(stream) - return stream.getvalue() - - @classmethod - def decode_bytes(cls, data: bytes) -> Self: - """Deserializes a byte string into an SSZVector instance.""" - with io.BytesIO(data) as stream: - return cls.deserialize(stream, len(data)) - def __len__(self) -> int: """Return the number of elements in the vector.""" return len(self.data) @@ -428,18 +415,6 @@ def deserialize(cls, stream: IO[bytes], scope: int) -> Self: return cls(data=elements) - def encode_bytes(self) -> bytes: - """Return the list's canonical SSZ byte representation.""" - with io.BytesIO() as stream: - self.serialize(stream) - return stream.getvalue() - - @classmethod - def decode_bytes(cls, data: bytes) -> Self: - """Deserializes a byte string into an SSZList instance.""" - with io.BytesIO(data) as stream: - return cls.deserialize(stream, len(data)) - def __len__(self) -> int: """Return the number of elements in the list.""" return len(self.data) diff --git a/src/lean_spec/types/constants.py b/src/lean_spec/types/constants.py index da67d95e..1bef237d 100644 --- a/src/lean_spec/types/constants.py +++ b/src/lean_spec/types/constants.py @@ -1,4 +1,8 @@ """Constants used throughout the library.""" -OFFSET_BYTE_LENGTH = 4 +from __future__ import annotations + +from typing import Final + +OFFSET_BYTE_LENGTH: Final = 4 """The number of bytes used to represent the offset of a variable-sized element.""" diff --git a/src/lean_spec/types/container.py b/src/lean_spec/types/container.py index 924075bc..cd8cce8a 100644 --- a/src/lean_spec/types/container.py +++ b/src/lean_spec/types/container.py @@ -9,7 +9,6 @@ from __future__ import annotations -import io from typing import IO, Any, Self from .constants import OFFSET_BYTE_LENGTH @@ -18,25 +17,6 @@ from .uint import Uint32 -def _get_ssz_field_type(annotation: Any) -> type[SSZType]: - """ - Extract the SSZType class from a field annotation, with validation. - - Args: - annotation: The field type annotation. - - Returns: - The SSZType class. - - Raises: - SSZTypeCoercionError: If the annotation is not a valid SSZType class. - """ - # Check if it's a class and is a subclass of SSZType - if not (isinstance(annotation, type) and issubclass(annotation, SSZType)): - raise SSZTypeError(f"Expected SSZType subclass, got {annotation}") - return annotation - - class Container(SSZModel): """ SSZ Container: A strict, ordered collection of heterogeneous named fields. @@ -60,6 +40,24 @@ class Container(SSZModel): [fixed_field_1][fixed_field_2]...[offset_1][offset_2]...[variable_data_1][variable_data_2]... """ + @staticmethod + def _get_ssz_field_type(annotation: Any) -> type[SSZType]: + """ + Extract the SSZType class from a field annotation, with validation. + + Args: + annotation: The field type annotation. + + Returns: + The SSZType class. + + Raises: + SSZTypeError: If the annotation is not a valid SSZType class. + """ + if not (isinstance(annotation, type) and issubclass(annotation, SSZType)): + raise SSZTypeError(f"Expected SSZType subclass, got {annotation}") + return annotation + @classmethod def is_fixed_size(cls) -> bool: """ @@ -73,7 +71,7 @@ def is_fixed_size(cls) -> bool: """ # Check each field's type for fixed size property return all( - _get_ssz_field_type(field.annotation).is_fixed_size() + cls._get_ssz_field_type(field.annotation).is_fixed_size() for field in cls.model_fields.values() ) @@ -94,7 +92,7 @@ def get_byte_length(cls) -> int: # Sum the byte lengths of all fixed-size fields return sum( - _get_ssz_field_type(field.annotation).get_byte_length() + cls._get_ssz_field_type(field.annotation).get_byte_length() for field in cls.model_fields.values() ) @@ -172,8 +170,7 @@ def deserialize(cls, stream: IO[bytes], scope: int) -> Self: New container instance with deserialized values. Raises: - SSZStreamError: If stream ends unexpectedly. - SSZOffsetError: If offsets are invalid. + SSZSerializationError: If stream ends unexpectedly or offsets are invalid. """ fields = {} # Collected field values var_fields = [] # (name, type, offset) for variable fields @@ -181,7 +178,7 @@ def deserialize(cls, stream: IO[bytes], scope: int) -> Self: # Phase 1: Read fixed part for field_name, field_info in cls.model_fields.items(): - field_type = _get_ssz_field_type(field_info.annotation) + field_type = cls._get_ssz_field_type(field_info.annotation) if field_type.is_fixed_size(): # Read and deserialize fixed field directly @@ -236,28 +233,3 @@ def deserialize(cls, stream: IO[bytes], scope: int) -> Self: # Construct container with all fields return cls(**fields) - - def encode_bytes(self) -> bytes: - """ - Encode container to bytes. - - Returns: - Serialized container as bytes. - """ - with io.BytesIO() as stream: - self.serialize(stream) - return stream.getvalue() - - @classmethod - def decode_bytes(cls, data: bytes) -> Self: - """ - Decode container from bytes. - - Args: - data: Serialized container bytes. - - Returns: - Deserialized container instance. - """ - with io.BytesIO(data) as stream: - return cls.deserialize(stream, len(data)) diff --git a/src/lean_spec/types/exceptions.py b/src/lean_spec/types/exceptions.py index c1fbe607..c768820c 100644 --- a/src/lean_spec/types/exceptions.py +++ b/src/lean_spec/types/exceptions.py @@ -1,5 +1,7 @@ """Exception hierarchy for the SSZ type system.""" +from __future__ import annotations + class SSZError(Exception): """Base exception for all SSZ-related errors.""" diff --git a/src/lean_spec/types/rlp.py b/src/lean_spec/types/rlp.py index dc557d54..2ee8b9f7 100644 --- a/src/lean_spec/types/rlp.py +++ b/src/lean_spec/types/rlp.py @@ -38,9 +38,9 @@ from __future__ import annotations -from typing import TypeAlias +from typing import Final -RLPItem: TypeAlias = bytes | list["RLPItem"] +type RLPItem = bytes | list[RLPItem] """ RLP-encodable item. @@ -50,25 +50,25 @@ """ -SINGLE_BYTE_MAX = 0x7F +SINGLE_BYTE_MAX: Final = 0x7F """Boundary between single-byte encoding [0x00-0x7f] and string prefix.""" -SHORT_STRING_PREFIX = 0x80 +SHORT_STRING_PREFIX: Final = 0x80 """Prefix for short strings (0-55 bytes). Final prefix = 0x80 + length.""" -SHORT_STRING_MAX_LEN = 55 +SHORT_STRING_MAX_LEN: Final = 55 """Maximum string length for short encoding.""" -LONG_STRING_BASE = 0xB7 +LONG_STRING_BASE: Final = 0xB7 """Base for long string prefix. Final prefix = 0xb7 + length_of_length.""" -SHORT_LIST_PREFIX = 0xC0 +SHORT_LIST_PREFIX: Final = 0xC0 """Prefix for short lists (0-55 bytes payload). Final prefix = 0xc0 + length.""" -SHORT_LIST_MAX_LEN = 55 +SHORT_LIST_MAX_LEN: Final = 55 """Maximum list payload length for short encoding.""" -LONG_LIST_BASE = 0xF7 +LONG_LIST_BASE: Final = 0xF7 """Base for long list prefix. Final prefix = 0xf7 + length_of_length.""" diff --git a/src/lean_spec/types/ssz_base.py b/src/lean_spec/types/ssz_base.py index 92c5f919..222e8b97 100644 --- a/src/lean_spec/types/ssz_base.py +++ b/src/lean_spec/types/ssz_base.py @@ -4,7 +4,8 @@ import io from abc import ABC, abstractmethod -from typing import IO, Any, Self, Sequence +from collections.abc import Sequence +from typing import IO, Any, Self from .base import StrictBaseModel diff --git a/src/lean_spec/types/uint.py b/src/lean_spec/types/uint.py index 42c53f6b..2c089c94 100644 --- a/src/lean_spec/types/uint.py +++ b/src/lean_spec/types/uint.py @@ -24,8 +24,8 @@ def __new__(cls, value: SupportsInt) -> Self: Create and validate a new Uint instance. Raises: - SSZTypeCoercionError: If `value` is not an int (rejects bool, string, float). - SSZOverflowError: If `value` is outside the allowed range [0, 2**BITS - 1]. + SSZTypeError: If `value` is not an int (rejects bool, string, float). + SSZValueError: If `value` is outside the allowed range [0, 2**BITS - 1]. """ # We should accept only ints. if not isinstance(value, int) or isinstance(value, bool): diff --git a/src/lean_spec/types/union.py b/src/lean_spec/types/union.py index 9699b5bc..5d6cc4fd 100644 --- a/src/lean_spec/types/union.py +++ b/src/lean_spec/types/union.py @@ -2,7 +2,6 @@ from __future__ import annotations -import io from typing import ( IO, Any, @@ -182,17 +181,6 @@ def deserialize(cls, stream: IO[bytes], scope: int) -> Self: f"{cls.__name__}: failed to deserialize {selected_type.__name__}: {e}" ) from e - def encode_bytes(self) -> bytes: - """Encode this Union to bytes.""" - with io.BytesIO() as stream: - self.serialize(stream) - return stream.getvalue() - - @classmethod - def decode_bytes(cls, data: bytes) -> Self: - """Decode a Union from bytes.""" - return cls.deserialize(io.BytesIO(data), len(data)) - def __repr__(self) -> str: """Return a readable string representation of this Union.""" return f"{type(self).__name__}(selector={self.selector}, value={self.value!r})" diff --git a/tests/lean_spec/subspecs/networking/discovery/test_codec.py b/tests/lean_spec/subspecs/networking/discovery/test_codec.py index 9d037347..625e112e 100644 --- a/tests/lean_spec/subspecs/networking/discovery/test_codec.py +++ b/tests/lean_spec/subspecs/networking/discovery/test_codec.py @@ -8,7 +8,6 @@ _decode_request_id, decode_message, encode_message, - generate_request_id, ) from lean_spec.subspecs.networking.discovery.messages import ( Distance, @@ -337,13 +336,13 @@ class TestRequestIdGeneration: def test_generates_8_byte_id(self): """Test that generated request ID is 8 bytes.""" - request_id = generate_request_id() + request_id = RequestId.generate() assert len(request_id) == 8 def test_generates_different_ids(self): """Test that each generation produces a different ID.""" - id1 = generate_request_id() - id2 = generate_request_id() + id1 = RequestId.generate() + id2 = RequestId.generate() assert id1 != id2 diff --git a/tests/lean_spec/subspecs/networking/discovery/test_integration.py b/tests/lean_spec/subspecs/networking/discovery/test_integration.py index 3bc5816b..93353e8b 100644 --- a/tests/lean_spec/subspecs/networking/discovery/test_integration.py +++ b/tests/lean_spec/subspecs/networking/discovery/test_integration.py @@ -33,7 +33,6 @@ decode_whoareyou_authdata, encode_message_authdata, encode_packet, - generate_nonce, ) from lean_spec.subspecs.networking.discovery.routing import ( NodeEntry, @@ -93,7 +92,7 @@ def test_message_packet_encryption_roundtrip(self, node_a_keys, node_b_keys): # Create authdata. authdata = encode_message_authdata(node_a_keys["node_id"]) - nonce = generate_nonce() + nonce = Nonce.generate() # Encode packet. packet = encode_packet( diff --git a/tests/lean_spec/subspecs/networking/discovery/test_packet.py b/tests/lean_spec/subspecs/networking/discovery/test_packet.py index 30495bae..2439bb8b 100644 --- a/tests/lean_spec/subspecs/networking/discovery/test_packet.py +++ b/tests/lean_spec/subspecs/networking/discovery/test_packet.py @@ -18,8 +18,6 @@ encode_message_authdata, encode_packet, encode_whoareyou_authdata, - generate_id_nonce, - generate_nonce, ) from lean_spec.subspecs.networking.types import NodeId, SeqNumber from lean_spec.types import Bytes16, Bytes33, Bytes64 @@ -30,18 +28,18 @@ class TestNonceGeneration: def test_generate_nonce_is_12_bytes(self): """Test that generated nonce is 12 bytes.""" - nonce = generate_nonce() + nonce = Nonce.generate() assert len(nonce) == 12 def test_generate_id_nonce_is_16_bytes(self): """Test that generated ID nonce is 16 bytes.""" - id_nonce = generate_id_nonce() + id_nonce = IdNonce.generate() assert len(id_nonce) == 16 def test_generates_different_nonces(self): """Test that each generation produces different nonces.""" - nonce1 = generate_nonce() - nonce2 = generate_nonce() + nonce1 = Nonce.generate() + nonce2 = Nonce.generate() assert nonce1 != nonce2 diff --git a/tests/lean_spec/subspecs/networking/enr/test_enr.py b/tests/lean_spec/subspecs/networking/enr/test_enr.py index e45470f9..374000fc 100644 --- a/tests/lean_spec/subspecs/networking/enr/test_enr.py +++ b/tests/lean_spec/subspecs/networking/enr/test_enr.py @@ -25,7 +25,7 @@ from lean_spec.subspecs.networking.types import SeqNumber from lean_spec.types import Bytes64, SSZValueError, Uint64 from lean_spec.types.byte_arrays import Bytes4 -from lean_spec.types.rlp import encode_rlp +from lean_spec.types.rlp import RLPItem, encode_rlp # From: https://eips.ethereum.org/EIPS/eip-778 # @@ -1218,7 +1218,7 @@ def test_self_signed_enr_verifies(self) -> None: ) # Create content (keys must be sorted). - content_items: list[bytes] = [ + content_items: list[RLPItem] = [ b"\x01", b"id", b"v4", diff --git a/tests/lean_spec/subspecs/networking/gossipsub/test_gossipsub.py b/tests/lean_spec/subspecs/networking/gossipsub/test_gossipsub.py index 5b3da653..ef8883e9 100644 --- a/tests/lean_spec/subspecs/networking/gossipsub/test_gossipsub.py +++ b/tests/lean_spec/subspecs/networking/gossipsub/test_gossipsub.py @@ -23,8 +23,6 @@ ControlPrune, Message, SubOpts, - create_graft_rpc, - create_subscription_rpc, ) @@ -476,14 +474,14 @@ def test_rpc_empty_check(self) -> None: def test_rpc_helper_functions(self) -> None: """Test RPC creation helper functions.""" - assert create_subscription_rpc(["/topic1", "/topic2"], subscribe=True) == RPC( + assert RPC.subscription(["/topic1", "/topic2"], subscribe=True) == RPC( subscriptions=[ SubOpts(subscribe=True, topic_id="/topic1"), SubOpts(subscribe=True, topic_id="/topic2"), ] ) - assert create_graft_rpc(["/topic1"]) == RPC( + assert RPC.graft(["/topic1"]) == RPC( control=ControlMessage(graft=[ControlGraft(topic_id="/topic1")]) ) diff --git a/tests/lean_spec/test_cli.py b/tests/lean_spec/test_cli.py index 90a47f22..814570f0 100644 --- a/tests/lean_spec/test_cli.py +++ b/tests/lean_spec/test_cli.py @@ -28,7 +28,7 @@ from lean_spec.subspecs.ssz.hash import hash_tree_root from lean_spec.subspecs.sync.checkpoint_sync import CheckpointSyncError from lean_spec.types import Bytes32, Uint64 -from lean_spec.types.rlp import encode_rlp +from lean_spec.types.rlp import RLPItem, encode_rlp from tests.lean_spec.helpers import make_genesis_state # Generate a test keypair once for all ENR tests. @@ -40,7 +40,7 @@ ) -def _sign_enr_content(content_items: list[bytes]) -> bytes: +def _sign_enr_content(content_items: list[RLPItem]) -> bytes: """Sign ENR content and return 64-byte r||s signature.""" content_rlp = encode_rlp(content_items) @@ -56,7 +56,7 @@ def _sign_enr_content(content_items: list[bytes]) -> bytes: def _make_enr_with_udp(ip_bytes: bytes, udp_port: int) -> str: """Create a properly signed ENR string with IPv4 and UDP port.""" # Content items (keys must be sorted). - content_items: list[bytes] = [ + content_items: list[RLPItem] = [ b"\x01", # seq = 1 b"id", b"v4", @@ -76,7 +76,7 @@ def _make_enr_with_udp(ip_bytes: bytes, udp_port: int) -> str: 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[bytes] = [ + content_items: list[RLPItem] = [ b"\x01", # seq = 1 b"id", b"v4", @@ -96,7 +96,7 @@ def _make_enr_with_ipv6_udp(ip6_bytes: bytes, udp_port: int) -> str: def _make_enr_without_udp(ip_bytes: bytes) -> str: """Create a properly signed ENR string with IPv4 but no UDP port.""" - content_items: list[bytes] = [ + content_items: list[RLPItem] = [ b"\x01", # seq = 1 b"id", b"v4",