From a259e7d86519c7e775c97365fe84d12ba1625bb8 Mon Sep 17 00:00:00 2001 From: Thomas Coratger Date: Fri, 20 Feb 2026 17:35:17 +0100 Subject: [PATCH 1/4] chore: python and typing improvements --- .claude/ralph-loop.local.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .claude/ralph-loop.local.md diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md new file mode 100644 index 00000000..305fe8f0 --- /dev/null +++ b/.claude/ralph-loop.local.md @@ -0,0 +1,9 @@ +--- +active: true +iteration: 137 +max_iterations: 0 +completion_promise: "DONE" +started_at: "2026-02-20T14:14:04Z" +--- + +Can you go with the py architect to sanity check the codebase everywhere to check that the code adheres to all the most modern Python principles. It must be extremely lean, clean, and compact so that it truly serves as a minimal running specification/client that is considered a reference worldwide. Therefore, take inspiration from the best repositories in the world, or even from the Python compiler itself, and create something perfect. Of course, as a specification, it's crucial that things are organized correctly and clearly, so we'll prioritize, for example, storing functions in objects rather than isolated functions, while avoiding excessive abstraction to prevent overly complexifying the codebase. From 0eb8aa2b4d2848c87343a3f76a122496af290247 Mon Sep 17 00:00:00 2001 From: Thomas Coratger Date: Fri, 20 Feb 2026 17:35:35 +0100 Subject: [PATCH 2/4] cleanup --- .claude/ralph-loop.local.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 .claude/ralph-loop.local.md diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md deleted file mode 100644 index 305fe8f0..00000000 --- a/.claude/ralph-loop.local.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -active: true -iteration: 137 -max_iterations: 0 -completion_promise: "DONE" -started_at: "2026-02-20T14:14:04Z" ---- - -Can you go with the py architect to sanity check the codebase everywhere to check that the code adheres to all the most modern Python principles. It must be extremely lean, clean, and compact so that it truly serves as a minimal running specification/client that is considered a reference worldwide. Therefore, take inspiration from the best repositories in the world, or even from the Python compiler itself, and create something perfect. Of course, as a specification, it's crucial that things are organized correctly and clearly, so we'll prioritize, for example, storing functions in objects rather than isolated functions, while avoiding excessive abstraction to prevent overly complexifying the codebase. From 15d3b79ab89829664546f9c3109ad9937d160129 Mon Sep 17 00:00:00 2001 From: Thomas Coratger Date: Sat, 21 Feb 2026 00:31:34 +0100 Subject: [PATCH 3/4] core: better python --- src/lean_spec/__main__.py | 3 +- src/lean_spec/config.py | 5 +- src/lean_spec/snappy/__init__.py | 10 --- src/lean_spec/snappy/constants.py | 46 ++++++------ src/lean_spec/snappy/framing.py | 14 ++-- .../subspecs/api/endpoints/health.py | 5 +- src/lean_spec/subspecs/api/routes.py | 2 + src/lean_spec/subspecs/chain/clock.py | 2 + src/lean_spec/subspecs/chain/config.py | 4 +- src/lean_spec/subspecs/containers/slot.py | 3 +- .../subspecs/containers/state/state.py | 4 +- src/lean_spec/subspecs/koalabear/field.py | 14 ++-- .../networking/client/event_source.py | 4 +- src/lean_spec/subspecs/networking/config.py | 12 +--- .../subspecs/networking/discovery/codec.py | 7 -- .../subspecs/networking/discovery/config.py | 2 + .../subspecs/networking/discovery/crypto.py | 14 ++-- .../networking/discovery/handshake.py | 10 +-- .../subspecs/networking/discovery/keys.py | 5 +- .../subspecs/networking/discovery/messages.py | 24 +++++-- .../subspecs/networking/discovery/packet.py | 20 ++---- .../subspecs/networking/discovery/service.py | 7 +- .../subspecs/networking/discovery/session.py | 7 +- .../networking/discovery/transport.py | 17 +++-- src/lean_spec/subspecs/networking/enr/enr.py | 4 +- src/lean_spec/subspecs/networking/enr/eth2.py | 4 +- src/lean_spec/subspecs/networking/enr/keys.py | 4 +- .../subspecs/networking/gossipsub/behavior.py | 16 ++--- .../subspecs/networking/gossipsub/message.py | 2 +- .../subspecs/networking/gossipsub/rpc.py | 57 +++++++-------- .../subspecs/networking/gossipsub/topic.py | 11 +-- .../subspecs/networking/reqresp/handler.py | 5 +- .../subspecs/networking/reqresp/message.py | 6 +- .../subspecs/networking/service/events.py | 2 +- .../subspecs/networking/transport/quic/tls.py | 8 +-- src/lean_spec/subspecs/networking/types.py | 6 +- src/lean_spec/subspecs/node/node.py | 3 +- src/lean_spec/subspecs/poseidon2/constants.py | 2 + .../subspecs/poseidon2/permutation.py | 2 + src/lean_spec/subspecs/ssz/constants.py | 10 ++- src/lean_spec/subspecs/ssz/merkleization.py | 26 ++++--- src/lean_spec/subspecs/ssz/pack.py | 2 +- src/lean_spec/subspecs/ssz/utils.py | 2 + src/lean_spec/subspecs/storage/namespaces.py | 11 +-- .../subspecs/sync/checkpoint_sync.py | 5 +- src/lean_spec/subspecs/sync/peer_manager.py | 11 +-- src/lean_spec/subspecs/validator/registry.py | 2 +- src/lean_spec/subspecs/validator/service.py | 4 +- src/lean_spec/subspecs/xmss/aggregation.py | 5 +- src/lean_spec/subspecs/xmss/constants.py | 4 +- src/lean_spec/subspecs/xmss/containers.py | 3 +- src/lean_spec/subspecs/xmss/hypercube.py | 3 +- src/lean_spec/subspecs/xmss/poseidon.py | 6 +- src/lean_spec/subspecs/xmss/prf.py | 28 ++------ src/lean_spec/subspecs/xmss/rand.py | 4 +- src/lean_spec/subspecs/xmss/target_sum.py | 15 ++-- src/lean_spec/subspecs/xmss/types.py | 8 ++- src/lean_spec/subspecs/xmss/utils.py | 2 + src/lean_spec/types/base.py | 2 + src/lean_spec/types/bitfields.py | 2 +- src/lean_spec/types/boolean.py | 4 +- src/lean_spec/types/byte_arrays.py | 63 ++++++++-------- src/lean_spec/types/collections.py | 27 +------ src/lean_spec/types/constants.py | 6 +- src/lean_spec/types/container.py | 72 ++++++------------- src/lean_spec/types/exceptions.py | 2 + src/lean_spec/types/rlp.py | 18 ++--- src/lean_spec/types/ssz_base.py | 3 +- src/lean_spec/types/uint.py | 4 +- src/lean_spec/types/union.py | 12 ---- .../networking/discovery/test_codec.py | 7 +- .../networking/discovery/test_integration.py | 3 +- .../networking/discovery/test_packet.py | 10 ++- .../networking/gossipsub/test_gossipsub.py | 6 +- 74 files changed, 354 insertions(+), 411 deletions(-) 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..a2db20ce 100644 --- a/src/lean_spec/subspecs/networking/discovery/codec.py +++ b/src/lean_spec/subspecs/networking/discovery/codec.py @@ -20,8 +20,6 @@ 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.rlp import RLPDecodingError @@ -300,8 +298,3 @@ def _decode_talkresp(payload: bytes) -> 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..736da51d 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 @@ -63,7 +63,7 @@ from .eth2 import AttestationSubnets, Eth2Data, SyncCommitteeSubnets from .keys import EnrKey -ENR_PREFIX = "enr:" +ENR_PREFIX: Final = "enr:" """Text prefix for ENR strings.""" 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/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")]) ) From c14c83ce46bee1d00cd8ddc73672adf035785be1 Mon Sep 17 00:00:00 2001 From: Thomas Coratger Date: Sat, 21 Feb 2026 00:43:21 +0100 Subject: [PATCH 4/4] fix --- .../subspecs/networking/discovery/codec.py | 50 ++++++++++++------- src/lean_spec/subspecs/networking/enr/enr.py | 5 +- .../subspecs/networking/enr/test_enr.py | 4 +- tests/lean_spec/test_cli.py | 10 ++-- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/src/lean_spec/subspecs/networking/discovery/codec.py b/src/lean_spec/subspecs/networking/discovery/codec.py index a2db20ce..92ea9f2b 100644 --- a/src/lean_spec/subspecs/networking/discovery/codec.py +++ b/src/lean_spec/subspecs/networking/discovery/codec.py @@ -21,13 +21,15 @@ from __future__ import annotations 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, @@ -158,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( @@ -181,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), ) @@ -212,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") @@ -219,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) @@ -240,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): @@ -250,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, ) @@ -268,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( @@ -290,8 +304,8 @@ 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( diff --git a/src/lean_spec/subspecs/networking/enr/enr.py b/src/lean_spec/subspecs/networking/enr/enr.py index 736da51d..d1fe3075 100644 --- a/src/lean_spec/subspecs/networking/enr/enr.py +++ b/src/lean_spec/subspecs/networking/enr/enr.py @@ -54,6 +54,7 @@ from lean_spec.types import ( Bytes33, Bytes64, + RLPItem, StrictBaseModel, Uint64, rlp, @@ -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/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/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",