diff --git a/.claude/agents/code-tester.md b/.claude/agents/code-tester.md index 534b924e3..4be80e069 100644 --- a/.claude/agents/code-tester.md +++ b/.claude/agents/code-tester.md @@ -17,7 +17,7 @@ The fork specs under `src/lean_spec/spec/forks/` are tested EXCLUSIVELY through - There is NO `tests/spec/forks/` tree, and you must never create one. - For ANY fork behavior — fork choice, state transition, block production, validator duties, aggregation, the containers, slot/interval math, the fork registry or protocol — write or update a consensus test-vector fixture (`state_transition`, `fork_choice`, `ssz`, `slot_clock`, `verify_signatures`, etc.), never a pytest. -- Mirrored pytest unit tests apply only to NON-fork modules (`node/`, `spec/crypto/`, `spec/ssz/`, and similar). +- Mirrored pytest unit tests apply only to NON-fork modules (`node/`, `spec/crypto/`, and similar). - If asked to "add tests" for a fork container or function (for example a new container under `spec/forks/lstar/containers/`), produce a consensus vector fixture, not a pytest under `tests/`. ## Auto-Invoke Skills diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 32f9fd0f5..0835ce754 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -24,13 +24,13 @@ paths: Bad: ```python def process(data): - from lean_spec.spec.crypto.merkleization import hash_tree_root + from ssz import hash_tree_root return hash_tree_root(data) ``` Good: ```python -from lean_spec.spec.crypto.merkleization import hash_tree_root +from ssz import hash_tree_root def process(data): return hash_tree_root(data) diff --git a/.claude/skills/audit/SKILL.md b/.claude/skills/audit/SKILL.md index 10b209e06..840c93696 100644 --- a/.claude/skills/audit/SKILL.md +++ b/.claude/skills/audit/SKILL.md @@ -85,7 +85,6 @@ orchestrator sharded the tree only so the work parallelizes. 2. **Shard.** Split the in-scope tree into coherent subsystems so agents run in parallel. The natural shards: - `src/lean_spec/spec/crypto/` (XMSS, hashing, signatures, aggregation) - - `src/lean_spec/spec/ssz/` - `src/lean_spec/spec/forks/` (state transition, fork choice, containers, validator duties, aggregation) - `src/lean_spec/node/networking/` (gossipsub, reqresp, quic, discovery) diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md index ea63c23de..e83c76df5 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -19,7 +19,7 @@ Pass additional arguments after `--`: - `/test -- -v` - Verbose output - `/test -- -k "test_serialize"` - Run matching tests -- `/test -- tests/spec/ssz/` - Run specific test directory +- `/test -- tests/spec/crypto/` - Run specific test directory ## Examples diff --git a/CLAUDE.md b/CLAUDE.md index 3ef997e79..7778c4fad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ subspecifications that the Lean Ethereum protocol relies on. - A test file must never test a type that lives in a different source module. For example, tests for `SlotClock` (in `node/chain/clock.py`) belong in `tests/node/chain/test_clock.py`, never in an unrelated test module. - - This mirroring covers non-fork modules only (`node/`, `spec/crypto/`, `spec/ssz/`, etc.). The + - This mirroring covers non-fork modules only (`node/`, `spec/crypto/`, etc.). The fork specs under `src/lean_spec/spec/forks/` are exempt — see the forks-are-vectors rule below. - **CRITICAL - FORKS ARE TESTED BY VECTORS, NOT PYTESTS**: This is a STRICT requirement. The fork specs under `src/lean_spec/spec/forks/` are tested exclusively through consensus test vectors diff --git a/README.md b/README.md index 586fa8d7c..126b4ae0b 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ just test │ └── spec/ # Protocol specifications │ ├── crypto/ # Cryptographic subspecs (poseidon, koalabear, xmss, ...) │ ├── forks/ # Fork specifications (tested via consensus vectors) -│ ├── ssz/ # SSZ serialization +│ ├── ssz_types.py # The SSZ shapes leanSpec declares itself │ └── observability/ # Observability spec ├── tests/ # Test suite │ ├── consensus/ # Consensus test vectors diff --git a/packages/testing/src/consensus_testing/genesis.py b/packages/testing/src/consensus_testing/genesis.py index 2b43634e1..e0a234995 100644 --- a/packages/testing/src/consensus_testing/genesis.py +++ b/packages/testing/src/consensus_testing/genesis.py @@ -1,7 +1,8 @@ """Consensus layer genesis state, block, and anchor construction for tests.""" +from ssz import Uint64, hash_tree_root + from consensus_testing.keys import XmssKeyManager -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Checkpoint, Interval, Slot, ValidatorIndex from lean_spec.spec.forks.lstar import Store from lean_spec.spec.forks.lstar.containers import ( @@ -16,7 +17,7 @@ Validators, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Bytes52, Uint64 +from lean_spec.spec.ssz_types import Bytes52 def build_genesis_state( diff --git a/packages/testing/src/consensus_testing/keys.py b/packages/testing/src/consensus_testing/keys.py index a636db3da..794125ef4 100755 --- a/packages/testing/src/consensus_testing/keys.py +++ b/packages/testing/src/consensus_testing/keys.py @@ -12,9 +12,10 @@ from pathlib import Path from typing import ClassVar, Literal +from ssz import hash_tree_root + from lean_spec.config import LEAN_ENV from lean_spec.spec.crypto.koalabear import Fp -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.crypto.xmss.constants import TARGET_CONFIG from lean_spec.spec.crypto.xmss.containers import ( PublicKey, @@ -39,7 +40,7 @@ AttestationData, SingleMessageAggregate, ) -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 KeyRole = Literal["attestation", "proposal"] """Discriminator for which signing role's key to load from a validator key pair.""" diff --git a/packages/testing/src/consensus_testing/keys_cli.py b/packages/testing/src/consensus_testing/keys_cli.py index b4ac6185b..760cad88f 100644 --- a/packages/testing/src/consensus_testing/keys_cli.py +++ b/packages/testing/src/consensus_testing/keys_cli.py @@ -15,6 +15,7 @@ from pathlib import Path import click +from ssz import Uint64 from consensus_testing.keys import ( LEAN_ENV_TO_SCHEMES, @@ -24,7 +25,6 @@ from lean_spec.spec.crypto.xmss.containers import ValidatorKeyPair from lean_spec.spec.crypto.xmss.interface import GeneralizedXmssScheme from lean_spec.spec.forks import Slot -from lean_spec.spec.ssz import Uint64 KEY_DOWNLOAD_URLS = { "test": "https://github.com/leanEthereum/leansig-test-keys/releases/download/latest/test_scheme.tar.gz", diff --git a/packages/testing/src/consensus_testing/mocks.py b/packages/testing/src/consensus_testing/mocks.py index b90c24d6b..6e7d5e634 100644 --- a/packages/testing/src/consensus_testing/mocks.py +++ b/packages/testing/src/consensus_testing/mocks.py @@ -8,6 +8,8 @@ from types import MappingProxyType from typing import cast +from ssz import Uint64, hash_tree_root + from lean_spec.node.chain.clock import SlotClock from lean_spec.node.networking import PeerId from lean_spec.node.networking.peer import PeerInfo @@ -17,7 +19,6 @@ from lean_spec.node.sync.block_cache import BlockCache from lean_spec.node.sync.peer_manager import PeerManager from lean_spec.node.sync.service import SyncService -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import ( Checkpoint, RejectionReason, @@ -34,7 +35,7 @@ State, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 @dataclass diff --git a/packages/testing/src/consensus_testing/pytest_plugins/filler.py b/packages/testing/src/consensus_testing/pytest_plugins/filler.py index 4953b59e8..c0a15db10 100644 --- a/packages/testing/src/consensus_testing/pytest_plugins/filler.py +++ b/packages/testing/src/consensus_testing/pytest_plugins/filler.py @@ -20,7 +20,7 @@ ProofSetting, ) from lean_spec.spec.forks import Slot, ValidatorIndex -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 class FixtureCollector: diff --git a/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py b/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py index 43dbde301..5f160bc30 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py +++ b/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py @@ -2,6 +2,8 @@ from typing import Any, ClassVar +from ssz import Uint64 + from consensus_testing.genesis import build_anchor from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec from consensus_testing.test_fixtures.hex_codec import to_hex @@ -10,7 +12,6 @@ from lean_spec.spec.forks import Slot from lean_spec.spec.forks.lstar import Store from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Uint64 REQUIRED_METRIC_NAMES = [ "lean_node_info", diff --git a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py index fbc900418..0abb2a7e5 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -5,6 +5,7 @@ from typing import ClassVar from pydantic import Field +from ssz import hash_tree_root from consensus_testing.genesis import build_genesis_state, reconstruct_block_from_header from consensus_testing.keys import XmssKeyManager @@ -23,7 +24,6 @@ TickStep, ) from lean_spec.node.chain.clock import SlotClock -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import ( Interval, RejectionReason, diff --git a/packages/testing/src/consensus_testing/test_fixtures/reaggregation.py b/packages/testing/src/consensus_testing/test_fixtures/reaggregation.py index 144e17833..7e9118341 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/reaggregation.py +++ b/packages/testing/src/consensus_testing/test_fixtures/reaggregation.py @@ -4,10 +4,11 @@ from typing import ClassVar +from ssz import hash_tree_root + from consensus_testing.keys import XmssKeyManager from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec from consensus_testing.test_fixtures.hex_codec import to_hex -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import AggregationBits, Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestation, @@ -18,7 +19,7 @@ MultiMessageAggregate, SingleMessageAggregate, ) -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 ATTESTATION_SLOT: Slot = Slot(1) """Attestation slot, one before the block that carries it.""" diff --git a/packages/testing/src/consensus_testing/test_fixtures/slot_clock.py b/packages/testing/src/consensus_testing/test_fixtures/slot_clock.py index 52d92742a..30c1719d7 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/slot_clock.py +++ b/packages/testing/src/consensus_testing/test_fixtures/slot_clock.py @@ -4,6 +4,7 @@ from typing import Annotated, ClassVar, Literal from pydantic import AfterValidator, Field +from ssz import Uint64 from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec from lean_spec.base import StrictBaseModel @@ -14,7 +15,6 @@ MILLISECONDS_PER_INTERVAL, SECONDS_PER_SLOT, ) -from lean_spec.spec.ssz import Uint64 def _reject_non_integral_timestamp(timestamp: float) -> float: diff --git a/packages/testing/src/consensus_testing/test_fixtures/ssz.py b/packages/testing/src/consensus_testing/test_fixtures/ssz.py index 0d751a7be..a0d2dcd0c 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/ssz.py +++ b/packages/testing/src/consensus_testing/test_fixtures/ssz.py @@ -2,15 +2,13 @@ from typing import Any, ClassVar -from pydantic import field_serializer +from pydantic import ValidationError, field_serializer +from ssz import Boolean, SSZError, SSZType, hash_tree_root from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec from consensus_testing.test_fixtures.hex_codec import from_hex, to_hex from lean_spec.base import CamelModel -from lean_spec.spec.crypto.koalabear import Fp -from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.ssz.boolean import Boolean -from lean_spec.spec.ssz.ssz_base import SSZType +from lean_spec.spec.ssz_types import ContainerInvariantError class SSZFixture(BaseConsensusFixture): @@ -43,8 +41,6 @@ def serialize_value(self, ssz_value: SSZType) -> Any: return to_hex(ssz_value) if isinstance(ssz_value, int): return str(ssz_value) - if isinstance(ssz_value, Fp): - return str(ssz_value.value) return str(ssz_value) @@ -104,7 +100,8 @@ def _generate_decode_failure(self) -> SSZFixture: exception_raised: Exception | None = None try: decoder.decode_bytes(raw) - except Exception as exception: + except (SSZError, ValidationError, ContainerInvariantError) as exception: + # Anything else is a bug in the filler, not an input every client must reject. exception_raised = exception return SSZFixture( diff --git a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py index fccac8c5f..c11c67fc4 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/state_transition.py +++ b/packages/testing/src/consensus_testing/test_fixtures/state_transition.py @@ -3,12 +3,12 @@ from typing import ClassVar from pydantic import Field, model_validator +from ssz import hash_tree_root from consensus_testing.genesis import build_genesis_state from consensus_testing.keys import XmssKeyManager from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec from consensus_testing.test_types import AggregatedAttestationSpec, BlockSpec, StateExpectation -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import SpecRejectionError from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestation, @@ -20,7 +20,7 @@ State, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 class StateTransitionFixture(BaseConsensusFixture): diff --git a/packages/testing/src/consensus_testing/test_fixtures/sync.py b/packages/testing/src/consensus_testing/test_fixtures/sync.py index 9b24d59a4..63b758352 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/sync.py +++ b/packages/testing/src/consensus_testing/test_fixtures/sync.py @@ -2,6 +2,8 @@ from typing import ClassVar, Literal +from ssz import Uint64 + from consensus_testing.genesis import build_anchor from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec from consensus_testing.test_fixtures.hex_codec import to_hex @@ -9,7 +11,6 @@ from lean_spec.node.sync.checkpoint_sync import verify_checkpoint_state from lean_spec.spec.forks import Slot from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Uint64 class VerifyCheckpointOutput(StrictBaseModel): diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py b/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py index 45aa5e08d..bd3c0003d 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py @@ -4,10 +4,11 @@ from typing import ClassVar +from ssz import hash_tree_root + from consensus_testing.keys import XmssKeyManager from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec from lean_spec.base import StrictBaseModel -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.crypto.xmss.containers import PublicKey from lean_spec.spec.forks import ( AggregationBits, @@ -21,7 +22,7 @@ MultiMessageAggregate, SingleMessageAggregate, ) -from lean_spec.spec.ssz import ByteList512KiB, Bytes32 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32 ALTERNATE_HEAD_ROOT: Bytes32 = Bytes32(b"\xee" * 32) """Sentinel head root used by the rebind tamper to bind one component off-target.""" diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py index a546fe15f..c0defbc6e 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_signatures.py @@ -5,13 +5,13 @@ from typing import ClassVar from pydantic import Field +from ssz import Boolean, hash_tree_root from consensus_testing.genesis import build_genesis_state from consensus_testing.keys import XmssKeyManager from consensus_testing.test_fixtures.base import BaseConsensusFixture, BaseTestSpec from consensus_testing.test_types import BlockSpec from lean_spec.base import StrictBaseModel -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import ( AggregationBits, Checkpoint, @@ -28,7 +28,7 @@ State, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Boolean, ByteList512KiB, Bytes32 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32 class SetProposerIndex(StrictBaseModel): diff --git a/packages/testing/src/consensus_testing/test_types/attestation_specs.py b/packages/testing/src/consensus_testing/test_types/attestation_specs.py index d0d0524fc..c7ed6528b 100644 --- a/packages/testing/src/consensus_testing/test_types/attestation_specs.py +++ b/packages/testing/src/consensus_testing/test_types/attestation_specs.py @@ -2,10 +2,11 @@ from __future__ import annotations +from ssz import hash_tree_root + from consensus_testing.keys import XmssKeyManager, create_dummy_signature from consensus_testing.test_types.utils import resolve_checkpoint from lean_spec.base import CamelModel -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import AggregationBits, Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestation, @@ -19,7 +20,7 @@ Store, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import ByteList512KiB, Bytes32 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32 class AttestationSpec(CamelModel): diff --git a/packages/testing/src/consensus_testing/test_types/block_spec.py b/packages/testing/src/consensus_testing/test_types/block_spec.py index ea317b86f..e8493efac 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -5,11 +5,12 @@ import copy from collections import defaultdict +from ssz import Uint64, hash_tree_root + from consensus_testing.genesis import reconstruct_block_from_header from consensus_testing.keys import XmssKeyManager, create_dummy_signature from consensus_testing.test_types.attestation_specs import AggregatedAttestationSpec from lean_spec.base import CamelModel -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.crypto.xmss.containers import PublicKey, Signature from lean_spec.spec.forks import AggregationBits, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import ( @@ -27,7 +28,7 @@ Store, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import ByteList512KiB, Bytes32, Uint64 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32 class BlockSpec(CamelModel): diff --git a/packages/testing/src/consensus_testing/test_types/state_expectation.py b/packages/testing/src/consensus_testing/test_types/state_expectation.py index 668c8bbfa..59949064e 100644 --- a/packages/testing/src/consensus_testing/test_types/state_expectation.py +++ b/packages/testing/src/consensus_testing/test_types/state_expectation.py @@ -15,7 +15,7 @@ State, Validators, ) -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 class StateExpectation(SelectiveCheck): diff --git a/packages/testing/src/consensus_testing/test_types/store_checks.py b/packages/testing/src/consensus_testing/test_types/store_checks.py index 71851d2b6..a952fe079 100644 --- a/packages/testing/src/consensus_testing/test_types/store_checks.py +++ b/packages/testing/src/consensus_testing/test_types/store_checks.py @@ -3,14 +3,15 @@ from collections.abc import Callable from typing import Any, ClassVar, Literal +from ssz import ZERO_ROOT, hash_tree_root + from consensus_testing.test_types.selective_check import SelectiveCheck from consensus_testing.test_types.utils import resolve_block_root from lean_spec.base import CamelModel -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Interval, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import AttestationData, Block, Store from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import ZERO_HASH, Bytes32 +from lean_spec.spec.ssz_types import Bytes32 _ATTESTATION_SLOT_ACCESSORS: dict[str, Callable[[AttestationData], Slot]] = { "attestation_slot": lambda attestation: attestation.slot, @@ -28,7 +29,7 @@ def _ancestor_set(blocks: dict[Bytes32, Block], head: Bytes32) -> set[Bytes32]: while root in blocks: seen.add(root) parent = blocks[root].parent_root - if parent == ZERO_HASH: + if parent == ZERO_ROOT: break root = parent return seen diff --git a/packages/testing/src/consensus_testing/test_types/store_snapshot.py b/packages/testing/src/consensus_testing/test_types/store_snapshot.py index 7a38ce3b6..e08b0ae35 100644 --- a/packages/testing/src/consensus_testing/test_types/store_snapshot.py +++ b/packages/testing/src/consensus_testing/test_types/store_snapshot.py @@ -1,7 +1,8 @@ """Canonical store snapshot emitted after every fork choice step.""" +from ssz import hash_tree_root + from lean_spec.base import StrictBaseModel -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Interval from lean_spec.spec.forks.lstar.containers import ( AttestationData, @@ -10,7 +11,7 @@ Store, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 class BlockWeightEntry(StrictBaseModel): diff --git a/packages/testing/src/consensus_testing/test_types/utils.py b/packages/testing/src/consensus_testing/test_types/utils.py index f4a5bec04..6970021a4 100644 --- a/packages/testing/src/consensus_testing/test_types/utils.py +++ b/packages/testing/src/consensus_testing/test_types/utils.py @@ -2,10 +2,11 @@ from __future__ import annotations -from lean_spec.spec.crypto.merkleization import hash_tree_root +from ssz import hash_tree_root + from lean_spec.spec.forks import Checkpoint, Slot from lean_spec.spec.forks.lstar.containers import Block -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 def resolve_block_root( diff --git a/packages/testing/src/consensus_testing/values.py b/packages/testing/src/consensus_testing/values.py index 111afc673..bcaa938a4 100644 --- a/packages/testing/src/consensus_testing/values.py +++ b/packages/testing/src/consensus_testing/values.py @@ -17,7 +17,7 @@ SignedAttestation, SignedBlock, ) -from lean_spec.spec.ssz import ByteList512KiB, Bytes32 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32 TEST_VALIDATOR_INDEX = ValidatorIndex(0) """Validator index a node owns by default in unit tests.""" diff --git a/pyproject.toml b/pyproject.toml index 6cf74fc8e..b4f116573 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ requires-python = ">=3.12" dependencies = [ "pydantic>=2.12.0,<3", "typing-extensions>=4.4", + "eth-ssz-specs>=0.1.0,<0.2", "lean-multisig-py>=0.0.9", "httpx>=0.28.0,<1", "aiohttp>=3.11.0,<4", @@ -83,7 +84,7 @@ ban-relative-imports = "all" # These types are immutable, so constructing one as an argument default is safe. # Uint64 and ValidatorIndex are frozen integer types; the fork facade is stateless. extend-immutable-calls = [ - "lean_spec.spec.ssz.Uint64", + "ssz.Uint64", "lean_spec.spec.forks.ValidatorIndex", "lean_spec.spec.forks.lstar.spec.LstarSpec", ] diff --git a/src/lean_spec/cli/run.py b/src/lean_spec/cli/run.py index b2b485a09..ce521be04 100644 --- a/src/lean_spec/cli/run.py +++ b/src/lean_spec/cli/run.py @@ -25,7 +25,7 @@ from lean_spec.spec.forks import SignedBlock, Slot, SubnetId from lean_spec.spec.forks.lstar.config import ATTESTATION_COMMITTEE_COUNT from lean_spec.spec.observability import set_observer -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/node/anchor.py b/src/lean_spec/node/anchor.py index 2e5f26686..ee809dc1a 100644 --- a/src/lean_spec/node/anchor.py +++ b/src/lean_spec/node/anchor.py @@ -14,6 +14,8 @@ from typing import cast +from ssz import hash_tree_root + from lean_spec.base import StrictBaseModel from lean_spec.node.genesis import GenesisConfig from lean_spec.node.networking.reqresp.message import Status @@ -23,7 +25,6 @@ fetch_finalized_state, verify_checkpoint_state, ) -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import ( Checkpoint, ForkProtocol, @@ -32,7 +33,7 @@ ValidatorIndex, Validators, ) -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 class Anchor(StrictBaseModel): diff --git a/src/lean_spec/node/api/context.py b/src/lean_spec/node/api/context.py index 99ada6249..517e74548 100644 --- a/src/lean_spec/node/api/context.py +++ b/src/lean_spec/node/api/context.py @@ -9,7 +9,7 @@ from aiohttp import web from lean_spec.spec.forks import LstarSpec, SignedBlock, Store -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 class AggregatorRoleControl(Protocol): diff --git a/src/lean_spec/node/api/responses.py b/src/lean_spec/node/api/responses.py index cf3477e03..fb81f792e 100644 --- a/src/lean_spec/node/api/responses.py +++ b/src/lean_spec/node/api/responses.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict from lean_spec.spec.forks.lstar.containers import Slot -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 class ApiResponseBody(BaseModel): diff --git a/src/lean_spec/node/api/server.py b/src/lean_spec/node/api/server.py index 70d0a8d6a..a9f569a2a 100644 --- a/src/lean_spec/node/api/server.py +++ b/src/lean_spec/node/api/server.py @@ -12,7 +12,7 @@ from lean_spec.node.api.context import AggregatorRoleControl, ApiContext from lean_spec.node.api.handlers import ApiHandlers from lean_spec.spec.forks import LstarSpec, SignedBlock, Store -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/node/chain/clock.py b/src/lean_spec/node/chain/clock.py index 0095f5fb0..3397dfe62 100644 --- a/src/lean_spec/node/chain/clock.py +++ b/src/lean_spec/node/chain/clock.py @@ -7,12 +7,13 @@ from dataclasses import dataclass from time import time as wall_time +from ssz import Uint64 + from lean_spec.spec.forks import Interval, Slot from lean_spec.spec.forks.lstar.config import ( MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, ) -from lean_spec.spec.ssz import Uint64 @dataclass(frozen=True, slots=True) diff --git a/src/lean_spec/node/genesis.py b/src/lean_spec/node/genesis.py index 536c8cf05..ea777229b 100644 --- a/src/lean_spec/node/genesis.py +++ b/src/lean_spec/node/genesis.py @@ -7,6 +7,7 @@ import yaml from pydantic import Field, field_validator +from ssz import Uint64 from lean_spec.base import StrictBaseModel from lean_spec.spec.forks import ( @@ -15,7 +16,7 @@ ValidatorIndex, Validators, ) -from lean_spec.spec.ssz import Bytes52, Uint64 +from lean_spec.spec.ssz_types import Bytes52 class GenesisValidatorEntry(StrictBaseModel): diff --git a/src/lean_spec/node/networking/client/event_source/live.py b/src/lean_spec/node/networking/client/event_source/live.py index f379addd2..e00d6ab88 100644 --- a/src/lean_spec/node/networking/client/event_source/live.py +++ b/src/lean_spec/node/networking/client/event_source/live.py @@ -61,6 +61,8 @@ from collections.abc import Sequence from dataclasses import dataclass, field +from ssz import SSZValueError + from lean_spec.node.networking.client.event_source.gossip import GossipHandler from lean_spec.node.networking.client.event_source.protocol import ( SUPPORTED_PROTOCOLS, @@ -110,7 +112,6 @@ ) from lean_spec.node.networking.types import ProtocolId from lean_spec.spec.forks import SignedAggregatedAttestation, SignedAttestation, SignedBlock -from lean_spec.spec.ssz.exceptions import SSZSerializationError logger = logging.getLogger(__name__) @@ -491,7 +492,7 @@ async def _handle_gossipsub_message(self, event: GossipsubMessageEvent) -> None: case TopicKind.AGGREGATED_ATTESTATION: aggregate = SignedAggregatedAttestation.decode_bytes(event.data) await self._emit_gossip_aggregated_attestation(aggregate, event.peer_id) - except SSZSerializationError as exception: + except SSZValueError as exception: raise GossipMessageError(f"SSZ decode failed: {exception}") from exception logger.debug("Processed gossipsub message %s from %s", topic.kind.value, event.peer_id) diff --git a/src/lean_spec/node/networking/client/reqresp_client.py b/src/lean_spec/node/networking/client/reqresp_client.py index 86d132933..55581e7f2 100644 --- a/src/lean_spec/node/networking/client/reqresp_client.py +++ b/src/lean_spec/node/networking/client/reqresp_client.py @@ -32,6 +32,8 @@ import logging from dataclasses import dataclass, field +from ssz import Uint64, hash_tree_root + from lean_spec.node.networking.config import MAX_REQUEST_BLOCKS from lean_spec.node.networking.reqresp.codec import ( CodecError, @@ -52,9 +54,8 @@ QuicConnection, QuicConnectionManager, ) -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import SignedBlock, Slot -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/node/networking/enr/enr.py b/src/lean_spec/node/networking/enr/enr.py index 1ebff3db9..31d1d1582 100644 --- a/src/lean_spec/node/networking/enr/enr.py +++ b/src/lean_spec/node/networking/enr/enr.py @@ -49,6 +49,7 @@ Prehashed, encode_dss_signature, ) +from ssz import Uint64 from lean_spec.base import StrictBaseModel from lean_spec.node.networking.enr import keys @@ -63,7 +64,7 @@ SeqNumber, Version, ) -from lean_spec.spec.ssz import Bytes33, Bytes64, Uint64 +from lean_spec.spec.ssz_types import Bytes33, Bytes64 ENR_PREFIX: Final = "enr:" """Text prefix for ENR strings.""" diff --git a/src/lean_spec/node/networking/enr/eth2.py b/src/lean_spec/node/networking/enr/eth2.py index 34ca70511..16ab62569 100644 --- a/src/lean_spec/node/networking/enr/eth2.py +++ b/src/lean_spec/node/networking/enr/eth2.py @@ -17,12 +17,12 @@ from typing import ClassVar, Final +from ssz import Boolean, Uint64 + from lean_spec.base import StrictBaseModel from lean_spec.node.networking.types import ForkDigest, Version from lean_spec.spec.forks import SubnetId -from lean_spec.spec.ssz import Uint64 -from lean_spec.spec.ssz.bitfields import BaseBitvector -from lean_spec.spec.ssz.boolean import Boolean +from lean_spec.spec.ssz_types import BitVector FAR_FUTURE_EPOCH: Final = Uint64(2**64 - 1) """Sentinel value indicating no scheduled fork.""" @@ -45,7 +45,7 @@ class Eth2Data(StrictBaseModel): """Epoch when next fork activates. FAR_FUTURE_EPOCH if none scheduled.""" -class AttestationSubnets(BaseBitvector): +class AttestationSubnets(BitVector): """ Attestation subnet subscriptions (ENR `attnets` key). diff --git a/src/lean_spec/node/networking/gossipsub/behavior.py b/src/lean_spec/node/networking/gossipsub/behavior.py index 56fc1e335..a0665fd5a 100644 --- a/src/lean_spec/node/networking/gossipsub/behavior.py +++ b/src/lean_spec/node/networking/gossipsub/behavior.py @@ -64,6 +64,8 @@ from itertools import count from typing import ClassVar, Final, cast +from ssz import Uint16 + from lean_spec.node.networking.config import ( MAX_PAYLOAD_SIZE, MESSAGE_DOMAIN_INVALID_SNAPPY, @@ -90,7 +92,6 @@ from lean_spec.node.networking.transport.quic.stream_adapter import QuicStreamAdapter from lean_spec.node.networking.varint import decode_varint, encode_varint from lean_spec.node.snappy import decompress as snappy_raw_decompress -from lean_spec.spec.ssz import Uint16 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/node/networking/gossipsub/types.py b/src/lean_spec/node/networking/gossipsub/types.py index 42c576010..286d97271 100644 --- a/src/lean_spec/node/networking/gossipsub/types.py +++ b/src/lean_spec/node/networking/gossipsub/types.py @@ -2,7 +2,7 @@ from __future__ import annotations -from lean_spec.spec.ssz import Bytes20 +from lean_spec.spec.ssz_types import Bytes20 class MessageId(Bytes20): diff --git a/src/lean_spec/node/networking/reqresp/handler.py b/src/lean_spec/node/networking/reqresp/handler.py index 250ce5e54..7170fb765 100644 --- a/src/lean_spec/node/networking/reqresp/handler.py +++ b/src/lean_spec/node/networking/reqresp/handler.py @@ -64,6 +64,8 @@ from dataclasses import dataclass from typing import Final +from ssz import Uint64 + from lean_spec.node.networking.config import ( MAX_ERROR_MESSAGE_SIZE, MAX_PAYLOAD_SIZE, @@ -84,7 +86,7 @@ from lean_spec.node.networking.varint import VarintError, decode_varint from lean_spec.node.snappy import SnappyDecompressionError, frame_decompress from lean_spec.spec.forks import SignedBlock, Slot -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/node/networking/reqresp/message.py b/src/lean_spec/node/networking/reqresp/message.py index 1a0cc702e..ec0b52199 100644 --- a/src/lean_spec/node/networking/reqresp/message.py +++ b/src/lean_spec/node/networking/reqresp/message.py @@ -7,11 +7,12 @@ from typing import ClassVar, Final +from ssz import Uint64 + from lean_spec.node.networking.config import MAX_REQUEST_BLOCKS from lean_spec.node.networking.types import ProtocolId from lean_spec.spec.forks import Checkpoint, Slot -from lean_spec.spec.ssz import Bytes32, SSZList, Uint64 -from lean_spec.spec.ssz.container import Container +from lean_spec.spec.ssz_types import Bytes32, Container, List STATUS_PROTOCOL_V1: Final = ProtocolId("/leanconsensus/req/status/1/ssz_snappy") """The protocol ID for the Status v1 request/response message.""" @@ -46,7 +47,7 @@ class Status(Container): """The protocol ID for the BlocksByRange v1 request/response message.""" -class RequestedBlockRoots(SSZList[Bytes32]): +class RequestedBlockRoots(List[Bytes32]): """List of block roots requested from a peer.""" LIMIT: ClassVar[int] = MAX_REQUEST_BLOCKS diff --git a/src/lean_spec/node/networking/transport/identity/keypair.py b/src/lean_spec/node/networking/transport/identity/keypair.py index f6861cc92..37efb07f0 100644 --- a/src/lean_spec/node/networking/transport/identity/keypair.py +++ b/src/lean_spec/node/networking/transport/identity/keypair.py @@ -17,7 +17,7 @@ from cryptography.hazmat.primitives.asymmetric import ec from lean_spec.node.networking.transport.peer_id import KeyType, PeerId, PublicKeyProtobuf -from lean_spec.spec.ssz import Bytes33 +from lean_spec.spec.ssz_types import Bytes33 __all__ = [ "IdentityKeypair", diff --git a/src/lean_spec/node/networking/types.py b/src/lean_spec/node/networking/types.py index ab90cc600..462b723ce 100644 --- a/src/lean_spec/node/networking/types.py +++ b/src/lean_spec/node/networking/types.py @@ -4,7 +4,9 @@ from enum import IntEnum, auto -from lean_spec.spec.ssz import Bytes4, Bytes32, Uint16, Uint64 +from ssz import Uint16, Uint64 + +from lean_spec.spec.ssz_types import Bytes4, Bytes32 class DomainType(Bytes4): diff --git a/src/lean_spec/node/node.py b/src/lean_spec/node/node.py index 9d23a3012..71cc98e7c 100644 --- a/src/lean_spec/node/node.py +++ b/src/lean_spec/node/node.py @@ -19,6 +19,8 @@ from pathlib import Path from typing import Final +from ssz import Uint64, hash_tree_root + from lean_spec.node.api import ApiServer, ApiServerConfig from lean_spec.node.chain import SlotClock from lean_spec.node.chain.service import ChainService @@ -28,7 +30,6 @@ from lean_spec.node.storage import Database, SQLiteDatabase from lean_spec.node.sync import BlockCache, NetworkRequester, PeerManager, SyncService from lean_spec.node.validator import ValidatorRegistry, ValidatorService -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import ( AggregatedAttestations, Block, @@ -45,7 +46,7 @@ ) from lean_spec.spec.forks.lstar.config import ATTESTATION_COMMITTEE_COUNT from lean_spec.spec.forks.lstar.containers import MultiMessageAggregate -from lean_spec.spec.ssz import ByteList512KiB, Bytes32, Uint64 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/node/storage/database.py b/src/lean_spec/node/storage/database.py index 2a2b26913..d0abeed21 100644 --- a/src/lean_spec/node/storage/database.py +++ b/src/lean_spec/node/storage/database.py @@ -6,12 +6,14 @@ from contextlib import contextmanager from typing import Protocol +from ssz import Uint64 + from lean_spec.spec.forks import Checkpoint, Slot from lean_spec.spec.forks.protocol import ( SpecBlockType, SpecStateType, ) -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 class Database(Protocol): diff --git a/src/lean_spec/node/storage/sqlite.py b/src/lean_spec/node/storage/sqlite.py index 4230c342c..6dce3ccc2 100644 --- a/src/lean_spec/node/storage/sqlite.py +++ b/src/lean_spec/node/storage/sqlite.py @@ -14,6 +14,8 @@ from contextlib import contextmanager from pathlib import Path +from ssz import Uint64 + from lean_spec.node.storage.exceptions import ( StorageCorruptionError, StorageReadError, @@ -42,7 +44,7 @@ SpecBlockType, SpecStateType, ) -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 class SQLiteDatabase: diff --git a/src/lean_spec/node/sync/backfill_sync.py b/src/lean_spec/node/sync/backfill_sync.py index f048c3ce5..f3b40335e 100644 --- a/src/lean_spec/node/sync/backfill_sync.py +++ b/src/lean_spec/node/sync/backfill_sync.py @@ -7,13 +7,15 @@ from itertools import batched from typing import Protocol +from ssz import Uint64 + from lean_spec.node.networking.config import MAX_REQUEST_BLOCKS from lean_spec.node.networking.transport.peer_id import PeerId from lean_spec.node.sync.block_cache import BlockCache from lean_spec.node.sync.config import MAX_BACKFILL_DEPTH from lean_spec.node.sync.peer_manager import PeerManager from lean_spec.spec.forks import SignedBlock, Slot -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/node/sync/block_cache.py b/src/lean_spec/node/sync/block_cache.py index a8d5659ed..2b6734fac 100644 --- a/src/lean_spec/node/sync/block_cache.py +++ b/src/lean_spec/node/sync/block_cache.py @@ -5,11 +5,12 @@ from collections import OrderedDict, defaultdict from dataclasses import dataclass, field +from ssz import hash_tree_root + from lean_spec.node.networking.transport.peer_id import PeerId from lean_spec.node.sync.config import MAX_CACHED_BLOCKS -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import SignedBlock, Slot -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 @dataclass(frozen=True, slots=True) diff --git a/src/lean_spec/node/sync/head_sync.py b/src/lean_spec/node/sync/head_sync.py index ed5c86654..81ae29ac0 100644 --- a/src/lean_spec/node/sync/head_sync.py +++ b/src/lean_spec/node/sync/head_sync.py @@ -7,12 +7,13 @@ from collections.abc import Callable from dataclasses import dataclass +from ssz import hash_tree_root + from lean_spec.node.networking.transport.peer_id import PeerId from lean_spec.node.sync.backfill_sync import BackfillSync from lean_spec.node.sync.block_cache import BlockCache -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import SignedBlock, Store -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/node/sync/service.py b/src/lean_spec/node/sync/service.py index 458cfb2ab..5beeef9e2 100644 --- a/src/lean_spec/node/sync/service.py +++ b/src/lean_spec/node/sync/service.py @@ -11,6 +11,8 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass, field +from ssz import hash_tree_root + from lean_spec.node.chain.clock import SlotClock from lean_spec.node.metrics import registry as metrics from lean_spec.node.networking.config import MIN_SLOTS_FOR_BLOCK_REQUESTS @@ -23,7 +25,6 @@ from lean_spec.node.sync.head_sync import HeadSync from lean_spec.node.sync.peer_manager import PeerManager from lean_spec.node.sync.states import SyncState -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.crypto.xmss.containers import PublicKey from lean_spec.spec.forks import ( AttestationData, @@ -42,7 +43,7 @@ AggregationError, SingleMessageAggregate, ) -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/node/validator/registry.py b/src/lean_spec/node/validator/registry.py index c33364372..3e07abedc 100644 --- a/src/lean_spec/node/validator/registry.py +++ b/src/lean_spec/node/validator/registry.py @@ -33,7 +33,7 @@ from lean_spec.spec.crypto.xmss import SecretKey from lean_spec.spec.forks import ValidatorIndex, ValidatorIndices -from lean_spec.spec.ssz import Bytes52 +from lean_spec.spec.ssz_types import Bytes52 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/node/validator/service.py b/src/lean_spec/node/validator/service.py index fb0650808..3b85c005d 100644 --- a/src/lean_spec/node/validator/service.py +++ b/src/lean_spec/node/validator/service.py @@ -8,6 +8,8 @@ from dataclasses import dataclass, field, replace from typing import Final, Literal +from ssz import Uint64, hash_tree_root + from lean_spec.node.chain.clock import SlotClock from lean_spec.node.sync import SyncService from lean_spec.node.validator.constants import ( @@ -16,7 +18,6 @@ SYNC_LAG_THRESHOLD, ) from lean_spec.node.validator.registry import ValidatorEntry, ValidatorRegistry -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.crypto.xmss import TARGET_SIGNATURE_SCHEME from lean_spec.spec.crypto.xmss.containers import PublicKey, Signature from lean_spec.spec.forks import ( @@ -30,7 +31,7 @@ ValidatorIndex, ) from lean_spec.spec.forks.lstar.containers import MultiMessageAggregate, SingleMessageAggregate -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 logger = logging.getLogger(__name__) diff --git a/src/lean_spec/spec/crypto/koalabear.py b/src/lean_spec/spec/crypto/koalabear.py index f6743050c..050c1e106 100644 --- a/src/lean_spec/spec/crypto/koalabear.py +++ b/src/lean_spec/spec/crypto/koalabear.py @@ -1,17 +1,11 @@ """Core definition of the KoalaBear prime field Fp.""" import math -from typing import IO, Any, Final, NoReturn, Self, override +from typing import IO, Any, Final, NoReturn, Self, cast, override from pydantic.annotated_handlers import GetCoreSchemaHandler from pydantic_core import core_schema - -from lean_spec.spec.ssz import SSZType -from lean_spec.spec.ssz.exceptions import ( - SSZSerializationError, - SSZTypeError, - SSZValueError, -) +from ssz import SSZTypeError, SSZValueError, TypeFault, Uint32, ValueFault P: Final = 2**31 - 2**24 + 1 """ @@ -27,18 +21,21 @@ """ -class Fp(int, SSZType): +class Fp(Uint32): """ An element in the KoalaBear prime field F_p. This is an SSZ-serializable type. Each field element is represented as a 4-byte little-endian unsigned integer. + The prime spans 31 bits, so the field rides on the 32-bit unsigned integer. + That inheritance is what packs a sequence of elements into shared chunks. + A type outside the unsigned integers would take a 32-byte leaf per element instead. """ __slots__ = () - def __new__(cls, value: int) -> Self: + def __new__(cls, value: int = 0) -> Self: """ Create a field element. @@ -53,10 +50,10 @@ def __new__(cls, value: int) -> Self: SSZTypeError: If value is not an integer. """ if not isinstance(value, int) or isinstance(value, bool): - raise SSZTypeError(f"Field value must be an integer, got {type(value).__name__}") + raise SSZTypeError(TypeFault.WRONG_TYPE, expected="int", got=type(value).__name__) # Normalize to [0, P) - handles negative values correctly - return super().__new__(cls, value % P) + return super().__new__(cls, int(value) % P) @classmethod def __get_pydantic_core_schema__( @@ -79,38 +76,34 @@ def __get_pydantic_core_schema__( ) @classmethod - @override - def is_fixed_size(cls) -> bool: - """Fp elements are fixed-size (4 bytes).""" - return True + def _require_canonical_residue(cls, decoded_integer: int) -> None: + """ + Refuse a non-canonical residue, since four bytes span more than the field holds. - @classmethod - @override - def get_byte_length(cls) -> int: - """Get the byte length of an Fp element.""" - return P_BYTES + Raises: + SSZValueError: If the decoded integer is at or above the modulus. + """ + if decoded_integer >= P: + raise SSZValueError( + ValueFault.RANGE, value=decoded_integer, type=cls.__name__, max=P - 1 + ) + @classmethod @override - def serialize(self, stream: IO[bytes]) -> int: - """Serialize the field element to a binary stream.""" - stream.write(int(self).to_bytes(P_BYTES, byteorder="little")) - return P_BYTES + def decode_bytes(cls, data: bytes) -> Self: + """Decode a field element from little-endian bytes.""" + # A type checker reads the inherited classmethod as returning the class it is written on. + element = cast(Self, super().decode_bytes(data)) + cls._require_canonical_residue(int(element)) + return element @classmethod @override def deserialize(cls, stream: IO[bytes], scope: int) -> Self: """Deserialize a field element from a binary stream.""" - if scope != P_BYTES: - raise SSZSerializationError(f"Expected {P_BYTES} bytes for Fp, got {scope}") - serialized_bytes = stream.read(P_BYTES) - if len(serialized_bytes) != P_BYTES: - raise SSZSerializationError( - f"Expected {P_BYTES} bytes for Fp, got {len(serialized_bytes)}" - ) - decoded_integer = int.from_bytes(serialized_bytes, byteorder="little") - if decoded_integer >= P: - raise SSZValueError(f"Value {decoded_integer} exceeds field modulus {P}") - return cls(decoded_integer) + element = cast(Self, super().deserialize(stream, scope)) + cls._require_canonical_residue(int(element)) + return element def _reject(self, other: Any, op_symbol: str) -> NoReturn: """Raise a consistent TypeError for a non-Fp operand.""" diff --git a/src/lean_spec/spec/crypto/merkleization.py b/src/lean_spec/spec/crypto/merkleization.py deleted file mode 100644 index 2832e4a7c..000000000 --- a/src/lean_spec/spec/crypto/merkleization.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Merkleization primitives and hash-tree-root dispatch for SSZ.""" - -from __future__ import annotations - -import math -from collections.abc import Sequence -from functools import singledispatch -from hashlib import sha256 -from itertools import accumulate, batched, repeat -from typing import Final - -from lean_spec.spec.crypto.koalabear import Fp -from lean_spec.spec.ssz import ZERO_HASH -from lean_spec.spec.ssz.bitfields import BaseBitlist, BaseBitvector -from lean_spec.spec.ssz.boolean import Boolean -from lean_spec.spec.ssz.byte_arrays import BaseByteList, BaseBytes, Bytes32 -from lean_spec.spec.ssz.collections import SSZList, SSZVector -from lean_spec.spec.ssz.container import Container -from lean_spec.spec.ssz.uint import BaseUint - -BYTES_PER_CHUNK: Final = 32 -"""Width of a Merkle leaf chunk in bytes.""" - -BITS_PER_CHUNK: Final = BYTES_PER_CHUNK * 8 -"""Width of a Merkle leaf chunk in bits.""" - - -def _next_pow2(x: int) -> int: - """ - Smallest power of two greater than or equal to x. - - Returns 1 when x is 0 or 1. - """ - if x <= 1: - return 1 - return 1 << (x - 1).bit_length() - - -_ZERO_HASHES: Final[tuple[Bytes32, ...]] = tuple( - accumulate( - repeat(None, 64), - lambda previous, _: Bytes32(sha256(previous + previous).digest()), - initial=ZERO_HASH, - ) -) -""" -Roots of perfect zero subtrees, indexed by depth. - -- Index 0 is the all-zero leaf. -- Index d is the root of a perfect binary tree of 2**d zero leaves. - -Depth 64 covers any chunk count the protocol uses. -""" - - -def _zero_tree_root(width: int) -> Bytes32: - """ - Root of an all-zero perfect binary tree with the given leaf count. - - The width must be a power of two. - """ - # A single-leaf tree has no parent to hash; the root is the leaf itself. - if width <= 1: - return ZERO_HASH - # A perfect binary tree with 2**d leaves has depth d. - # - # Subtract one before taking bit_length so a power of two maps to its own depth. - # - Width 2 -> depth 1, - # - Width 4 -> depth 2, - # - Width 1024 -> depth 10, - # - And so on. - depth = (width - 1).bit_length() - # The cache stores the all-zero subtree root at every depth. - # Index by depth to skip materializing 2**d zero leaves and the layers above them. - return _ZERO_HASHES[depth] - - -def merkleize(chunks: Sequence[Bytes32], limit: int | None = None) -> Bytes32: - r""" - Compute the SSZ Merkle root over a chunk sequence. - - Tree layout for three leaves with no limit: - - leaves : c0 c1 c2 ZERO (padded to next power of two) - \____/ \______/ - h01 h(c2, ZERO) - \______________/ - root - - When a limit is provided, the tree width is the next power of two of that limit. - Missing leaves contribute pre-computed zero subtree roots instead of - materialized zero chunks, so allocation stays proportional to actual data. - - Args: - chunks: Leaf chunks, each exactly 32 bytes wide. - limit: Optional leaf-count capacity; tree width is rounded up to the next power of two. - - Returns: - The Merkle root. - - Raises: - ValueError: If the chunk count exceeds the limit. - """ - chunk_count = len(chunks) - if chunk_count == 0: - return _zero_tree_root(_next_pow2(limit)) if limit is not None else ZERO_HASH - if limit is None: - width = _next_pow2(chunk_count) - elif limit < chunk_count: - raise ValueError("merkleize: input exceeds limit") - else: - width = _next_pow2(limit) - if width == 1: - return chunks[0] - - # Walk one tree layer per outer iteration. - # A missing right sibling pulls the all-zero subtree of the current size from the cache, - # so unused zero leaves are never allocated. - level: list[Bytes32] = list(chunks) - subtree_size = 1 - while subtree_size < width: - next_level: list[Bytes32] = [] - # Each pair holds the left and right child of one parent node. - # An odd tail yields a length-one tuple. - # Its missing right sibling is the all-zero subtree of the current size. - for child_pair in batched(level, 2): - left = child_pair[0] - right = child_pair[1] if len(child_pair) == 2 else _zero_tree_root(subtree_size) - next_level.append(Bytes32(sha256(left + right).digest())) - level = next_level - subtree_size *= 2 - - # Invariant: width is the next power of two of the leaf count or capacity, - # so the loop above halves the level count down to exactly one root. - assert len(level) == 1 - return level[0] - - -def mix_in_length(root: Bytes32, length: int) -> Bytes32: - """ - Mix a length into a Merkle root via the SSZ uint256 little-endian encoding. - - Variable-length types append their declared length to disambiguate roots. - Two lists with identical elements but different lengths must produce different roots. - - Args: - root: Merkle root over the data chunks. - length: Non-negative count to mix in. - - Returns: - The length-mixed root. - - Raises: - ValueError: If the length is negative. - """ - if length < 0: - raise ValueError("length must be non-negative") - return Bytes32(sha256(root + length.to_bytes(32, "little")).digest()) - - -def _pack_bytes(data: bytes) -> list[Bytes32]: - """ - Right-pad serialized bytes to a chunk boundary and split into chunks. - - Layout for a 5-byte payload: - - bytes : 01 02 03 04 05 - padded : 01 02 03 04 05 00 00 ... 00 (zero-padded to 32 bytes) - chunks : [ Bytes32(01 02 03 04 05 00 ...) ] - - Inner chunks are already chunk-aligned; only the trailing chunk is padded. - """ - return [ - Bytes32(data[i : i + BYTES_PER_CHUNK].ljust(BYTES_PER_CHUNK, b"\x00")) - for i in range(0, len(data), BYTES_PER_CHUNK) - ] - - -def _pack_bits(bits: Sequence[Boolean]) -> list[Bytes32]: - """ - Pack a boolean sequence into bytes, then into chunks for merkleization. - - The first input bit becomes the least significant bit of the first byte. - Each next input bit moves up one position, wrapping to the next byte after eight. - - Layout for [1, 0, 1, 1]: - - bit position : 7 6 5 4 3 2 1 0 - byte 0 : 0 0 0 0 1 1 0 1 - ^ ^ ^ ^ - 3 2 1 0 <- input order - - The SSZ serialization delimiter and the length-mix are separate steps, - handled by the caller when needed. - """ - packed_bits = sum(1 << i for i, bit in enumerate(bits) if bit) - return _pack_bytes(packed_bits.to_bytes(math.ceil(len(bits) / 8), "little")) - - -@singledispatch -def hash_tree_root(value: object) -> Bytes32: - """ - Compute the SSZ Merkle root of a value. - - Raises: - TypeError: If the value's type has no registered handler. - """ - raise TypeError(f"hash_tree_root: unsupported value type {type(value).__name__}") - - -@hash_tree_root.register(BaseUint) -@hash_tree_root.register(Boolean) -@hash_tree_root.register(Fp) -@hash_tree_root.register(BaseBytes) -def _hash_tree_root_packed_leaf(value: BaseUint | Boolean | Fp | BaseBytes) -> Bytes32: - # Each of these encodes to a fixed-width byte string with no length prefix. - # The root is the Merkle root of those bytes packed into 32-byte chunks. - return merkleize(_pack_bytes(value.encode_bytes())) - - -@hash_tree_root.register -def _hash_tree_root_bytes(value: bytes) -> Bytes32: - return merkleize(_pack_bytes(value)) - - -@hash_tree_root.register -def _hash_tree_root_bytelist(value: BaseByteList) -> Bytes32: - serialized_bytes = value.encode_bytes() - limit_chunks = math.ceil(type(value).LIMIT / BYTES_PER_CHUNK) - return mix_in_length( - merkleize(_pack_bytes(serialized_bytes), limit=limit_chunks), len(serialized_bytes) - ) - - -@hash_tree_root.register -def _hash_tree_root_bitvector_base(value: BaseBitvector) -> Bytes32: - limit = math.ceil(type(value).LENGTH / BITS_PER_CHUNK) - return merkleize(_pack_bits(value.data), limit=limit) - - -@hash_tree_root.register -def _hash_tree_root_bitlist_base(value: BaseBitlist) -> Bytes32: - limit = math.ceil(type(value).LIMIT / BITS_PER_CHUNK) - return mix_in_length( - merkleize(_pack_bits(value.data), limit=limit), - len(value.data), - ) - - -@hash_tree_root.register -def _hash_tree_root_vector(value: SSZVector) -> Bytes32: - cls = type(value) - element_type, length = cls.ELEMENT_TYPE, cls.LENGTH - if issubclass(element_type, (BaseUint, Boolean, Fp)): - # Basic elements pack their serialized bytes into a single byte stream before chunking. - element_size = element_type.get_byte_length() - limit_chunks = math.ceil(length * element_size / BYTES_PER_CHUNK) - return merkleize( - _pack_bytes(b"".join(e.encode_bytes() for e in value)), - limit=limit_chunks, - ) - # Composite elements each contribute their own hash tree root as a leaf. - return merkleize([hash_tree_root(e) for e in value], limit=length) - - -@hash_tree_root.register -def _hash_tree_root_list(value: SSZList) -> Bytes32: - cls = type(value) - element_type, limit = cls.ELEMENT_TYPE, cls.LIMIT - if issubclass(element_type, (BaseUint, Boolean, Fp)): - element_size = element_type.get_byte_length() - limit_chunks = math.ceil(limit * element_size / BYTES_PER_CHUNK) - root = merkleize( - _pack_bytes(b"".join(e.encode_bytes() for e in value)), - limit=limit_chunks, - ) - else: - root = merkleize([hash_tree_root(e) for e in value], limit=limit) - return mix_in_length(root, len(value)) - - -@hash_tree_root.register -def _hash_tree_root_container(value: Container) -> Bytes32: - # Pydantic preserves declaration order, which is the canonical SSZ field order. - cls = type(value) - return merkleize([hash_tree_root(getattr(value, name)) for name in cls.model_fields]) diff --git a/src/lean_spec/spec/crypto/xmss/constants.py b/src/lean_spec/spec/crypto/xmss/constants.py index aa47f2785..9b533e00a 100644 --- a/src/lean_spec/spec/crypto/xmss/constants.py +++ b/src/lean_spec/spec/crypto/xmss/constants.py @@ -4,12 +4,11 @@ from typing import Final, Self from pydantic import Field, model_validator +from ssz import BYTES_PER_LENGTH_OFFSET, Uint64 from lean_spec.base import StrictBaseModel from lean_spec.config import LEAN_ENV from lean_spec.spec.crypto.koalabear import P_BYTES, P -from lean_spec.spec.ssz import Uint64 -from lean_spec.spec.ssz.ssz_base import BYTES_PER_LENGTH_OFFSET class XmssConfig(StrictBaseModel): diff --git a/src/lean_spec/spec/crypto/xmss/containers.py b/src/lean_spec/spec/crypto/xmss/containers.py index 125e842a2..b3ecf9cde 100644 --- a/src/lean_spec/spec/crypto/xmss/containers.py +++ b/src/lean_spec/spec/crypto/xmss/containers.py @@ -3,6 +3,7 @@ from typing import Self, override from pydantic import model_serializer, model_validator +from ssz import Uint64 from lean_spec.base import StrictBaseModel from lean_spec.spec.crypto.xmss.constants import TARGET_CONFIG @@ -16,9 +17,7 @@ Randomness, ) from lean_spec.spec.forks.lstar.slot import Slot -from lean_spec.spec.ssz import Uint64 -from lean_spec.spec.ssz.container import Container -from lean_spec.spec.ssz.exceptions import SSZSerializationError +from lean_spec.spec.ssz_types import Container, ContainerInvariantError class HexSerializedContainer(Container): @@ -79,14 +78,14 @@ def _check_list_lengths(self) -> Self: """Pin the two variable-length lists to their scheme-constant counts.""" sibling_count = len(self.path.siblings) if sibling_count != TARGET_CONFIG.LOG_LIFETIME: - raise SSZSerializationError( + raise ContainerInvariantError( f"Signature.path.siblings requires exactly {TARGET_CONFIG.LOG_LIFETIME} " f"siblings, got {sibling_count}" ) hash_count = len(self.hashes) if hash_count != TARGET_CONFIG.DIMENSION: - raise SSZSerializationError( + raise ContainerInvariantError( f"Signature.hashes requires exactly {TARGET_CONFIG.DIMENSION} hashes, " f"got {hash_count}" ) @@ -95,14 +94,8 @@ def _check_list_lengths(self) -> Self: @classmethod @override - def is_fixed_size(cls) -> bool: - """Always fixed-size on the wire (see class docstring).""" - return True - - @classmethod - @override - def get_byte_length(cls) -> int: - """Fixed byte length of an SSZ-encoded signature.""" + def fixed_size(cls) -> int: + """The one byte length every signature encodes to (see class docstring).""" return TARGET_CONFIG.SIGNATURE_LENGTH_BYTES def __hash__(self) -> int: diff --git a/src/lean_spec/spec/crypto/xmss/encoding.py b/src/lean_spec/spec/crypto/xmss/encoding.py index 15ba7bce5..179cb084e 100644 --- a/src/lean_spec/spec/crypto/xmss/encoding.py +++ b/src/lean_spec/spec/crypto/xmss/encoding.py @@ -48,12 +48,14 @@ The decode rejects it, a rare event near 4.7e-10 that barely affects signing. """ +from ssz import Uint64 + from lean_spec.spec.crypto.koalabear import Fp from lean_spec.spec.crypto.xmss.constants import TWEAK_PREFIX_MESSAGE, XmssConfig from lean_spec.spec.crypto.xmss.field import int_to_base_p from lean_spec.spec.crypto.xmss.poseidon import PoseidonXmss from lean_spec.spec.crypto.xmss.types import Parameter, Randomness -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 def encode_message(config: XmssConfig, message: Bytes32) -> list[Fp]: diff --git a/src/lean_spec/spec/crypto/xmss/interface.py b/src/lean_spec/spec/crypto/xmss/interface.py index 451f399c2..746d4f0aa 100644 --- a/src/lean_spec/spec/crypto/xmss/interface.py +++ b/src/lean_spec/spec/crypto/xmss/interface.py @@ -1,5 +1,7 @@ """Public interface for the Generalized XMSS signature scheme.""" +from ssz import Uint64 + from lean_spec.base import StrictBaseModel from lean_spec.config import LEAN_ENV from lean_spec.spec.crypto.xmss.constants import PROD_CONFIG, TEST_CONFIG, XmssConfig @@ -11,7 +13,7 @@ from lean_spec.spec.crypto.xmss.prf import PRFKey from lean_spec.spec.crypto.xmss.types import HashDigestList, HashDigestVector from lean_spec.spec.forks.lstar.slot import Slot -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 def _expand_activation_time( diff --git a/src/lean_spec/spec/crypto/xmss/merkle.py b/src/lean_spec/spec/crypto/xmss/merkle.py index 00a46a668..7c7a0cfa5 100644 --- a/src/lean_spec/spec/crypto/xmss/merkle.py +++ b/src/lean_spec/spec/crypto/xmss/merkle.py @@ -29,6 +29,8 @@ from itertools import batched from typing import Self +from ssz import Uint64 + from lean_spec.spec.crypto.xmss.constants import TARGET_CONFIG, XmssConfig from lean_spec.spec.crypto.xmss.field import random_domain from lean_spec.spec.crypto.xmss.poseidon import PoseidonXmss @@ -40,9 +42,7 @@ Parameter, TreeTweak, ) -from lean_spec.spec.ssz import Uint64 -from lean_spec.spec.ssz.collections import SSZList -from lean_spec.spec.ssz.container import Container +from lean_spec.spec.ssz_types import Container, List class HashTreeLayer(Container): @@ -116,7 +116,7 @@ def padded( ) -class HashTreeLayers(SSZList[HashTreeLayer]): +class HashTreeLayers(List[HashTreeLayer]): """ The layers of a subtree, ordered from the lowest layer up to the root. diff --git a/src/lean_spec/spec/crypto/xmss/poseidon.py b/src/lean_spec/spec/crypto/xmss/poseidon.py index d648b58c7..a9e682d28 100644 --- a/src/lean_spec/spec/crypto/xmss/poseidon.py +++ b/src/lean_spec/spec/crypto/xmss/poseidon.py @@ -3,6 +3,7 @@ from itertools import batched from pydantic import PrivateAttr +from ssz import Uint64 from lean_spec.base import StrictBaseModel from lean_spec.spec.crypto.koalabear import Fp @@ -10,7 +11,6 @@ from lean_spec.spec.crypto.xmss.constants import TWEAK_PREFIX_CHAIN, TWEAK_PREFIX_TREE, XmssConfig from lean_spec.spec.crypto.xmss.field import int_to_base_p from lean_spec.spec.crypto.xmss.types import ChainTweak, HashDigestVector, Parameter, TreeTweak -from lean_spec.spec.ssz import Uint64 class PoseidonXmss(StrictBaseModel): diff --git a/src/lean_spec/spec/crypto/xmss/prf.py b/src/lean_spec/spec/crypto/xmss/prf.py index b290e29ad..6dbd3d7fd 100644 --- a/src/lean_spec/spec/crypto/xmss/prf.py +++ b/src/lean_spec/spec/crypto/xmss/prf.py @@ -5,11 +5,12 @@ from itertools import batched from typing import Final, Self +from ssz import ByteVector, Uint64 + from lean_spec.spec.crypto.koalabear import Fp from lean_spec.spec.crypto.xmss.constants import PRF_KEY_LENGTH, XmssConfig from lean_spec.spec.crypto.xmss.types import HashDigestVector, Randomness -from lean_spec.spec.ssz import Bytes16, Bytes32, Uint64 -from lean_spec.spec.ssz.byte_arrays import BaseBytes +from lean_spec.spec.ssz_types import Bytes16, Bytes32 PRF_DOMAIN_SEP: Final = Bytes16(b"\xae\xae\x22\xff\x00\x01\xfa\xff\x21\xaf\x12\x00\x01\x11\xff\x00") """ @@ -32,7 +33,7 @@ """ -class PRFKey(BaseBytes): +class PRFKey(ByteVector): """ The PRF master secret key. diff --git a/src/lean_spec/spec/crypto/xmss/types.py b/src/lean_spec/spec/crypto/xmss/types.py index acf866c63..11d936806 100644 --- a/src/lean_spec/spec/crypto/xmss/types.py +++ b/src/lean_spec/spec/crypto/xmss/types.py @@ -2,11 +2,11 @@ from typing import Final, NamedTuple +from ssz import Uint64 + from lean_spec.spec.crypto.koalabear import Fp from lean_spec.spec.crypto.xmss.constants import TARGET_CONFIG -from lean_spec.spec.ssz import Uint64 -from lean_spec.spec.ssz.collections import SSZList, SSZVector -from lean_spec.spec.ssz.container import Container +from lean_spec.spec.ssz_types import Container, List, Vector class TreeTweak(NamedTuple): @@ -45,7 +45,7 @@ class ChainTweak(NamedTuple): """ -class HashDigestVector(SSZVector[Fp]): +class HashDigestVector(Vector[Fp]): """ A single hash digest as a fixed-size vector of field elements. @@ -56,13 +56,13 @@ class HashDigestVector(SSZVector[Fp]): """One Poseidon digest, measured in field elements.""" -class HashDigestList(SSZList[HashDigestVector]): +class HashDigestList(List[HashDigestVector]): """Variable-length list of hash digests.""" LIMIT = NODE_LIST_LIMIT -class Parameter(SSZVector[Fp]): +class Parameter(Vector[Fp]): """ The public parameter P. @@ -74,7 +74,7 @@ class Parameter(SSZVector[Fp]): LENGTH = TARGET_CONFIG.PARAMETER_LENGTH -class Randomness(SSZVector[Fp]): +class Randomness(Vector[Fp]): """ Fresh randomness mixed into the message hash during signing. diff --git a/src/lean_spec/spec/forks/lstar/_base.py b/src/lean_spec/spec/forks/lstar/_base.py index 533004340..203a48fb1 100644 --- a/src/lean_spec/spec/forks/lstar/_base.py +++ b/src/lean_spec/spec/forks/lstar/_base.py @@ -3,6 +3,8 @@ from abc import abstractmethod from collections.abc import Set as AbstractSet +from ssz import Uint64 + from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestation, AggregatedAttestations, @@ -22,7 +24,7 @@ Validators, ) from lean_spec.spec.forks.protocol import ForkProtocol -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 LstarStore = Store[State, Block] """Concrete Store specialization owned by the lstar fork.""" diff --git a/src/lean_spec/spec/forks/lstar/aggregation.py b/src/lean_spec/spec/forks/lstar/aggregation.py index 4a19d2366..2f0ae57ac 100644 --- a/src/lean_spec/spec/forks/lstar/aggregation.py +++ b/src/lean_spec/spec/forks/lstar/aggregation.py @@ -1,6 +1,7 @@ """Lstar fork — attestation aggregation.""" -from lean_spec.spec.crypto.merkleization import hash_tree_root +from ssz import hash_tree_root + from lean_spec.spec.crypto.xmss.containers import PublicKey from lean_spec.spec.forks.lstar._base import LstarSpecBase, LstarStore from lean_spec.spec.forks.lstar.containers import ( diff --git a/src/lean_spec/spec/forks/lstar/block_production.py b/src/lean_spec/spec/forks/lstar/block_production.py index 7bf2cc957..09ae5fd16 100644 --- a/src/lean_spec/spec/forks/lstar/block_production.py +++ b/src/lean_spec/spec/forks/lstar/block_production.py @@ -3,7 +3,8 @@ from collections import defaultdict from collections.abc import Set as AbstractSet -from lean_spec.spec.crypto.merkleization import hash_tree_root +from ssz import ZERO_ROOT, hash_tree_root + from lean_spec.spec.crypto.xmss.containers import PublicKey from lean_spec.spec.forks.lstar._base import LstarSpecBase from lean_spec.spec.forks.lstar.aggregation import select_proofs_for_coverage @@ -20,7 +21,7 @@ State, ValidatorIndex, ) -from lean_spec.spec.ssz import ZERO_HASH, Bytes32 +from lean_spec.spec.ssz_types import Bytes32 class BlockProductionMixin(LstarSpecBase): @@ -117,7 +118,7 @@ def build_block( # Source and target roots are validated against this view. num_empty_slots = int(slot - state.latest_block_header.slot - Slot(1)) extended_historical_block_hashes: list[Bytes32] = ( - list(state.historical_block_hashes) + [parent_root] + [ZERO_HASH] * num_empty_slots + list(state.historical_block_hashes) + [parent_root] + [ZERO_ROOT] * num_empty_slots ) processed_attestation_data: set[AttestationData] = set() diff --git a/src/lean_spec/spec/forks/lstar/config.py b/src/lean_spec/spec/forks/lstar/config.py index 6b8eb45c2..509f8e24d 100644 --- a/src/lean_spec/spec/forks/lstar/config.py +++ b/src/lean_spec/spec/forks/lstar/config.py @@ -2,7 +2,7 @@ from typing import Final -from lean_spec.spec.ssz import Uint8, Uint64 +from ssz import Uint8, Uint64 __all__ = [ "ATTESTATION_COMMITTEE_COUNT", diff --git a/src/lean_spec/spec/forks/lstar/containers/aggregation.py b/src/lean_spec/spec/forks/lstar/containers/aggregation.py index e274838eb..0b50a43aa 100644 --- a/src/lean_spec/spec/forks/lstar/containers/aggregation.py +++ b/src/lean_spec/spec/forks/lstar/containers/aggregation.py @@ -18,7 +18,7 @@ from lean_spec.spec.forks.lstar.containers.identifiers import ValidatorIndex from lean_spec.spec.forks.lstar.containers.participation import AggregationBits from lean_spec.spec.forks.lstar.slot import Slot -from lean_spec.spec.ssz import ByteList512KiB, Bytes32, Container +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32, Container LOG_INVERSE_RATE: int = 1 if LEAN_ENV == "test" else 2 """ diff --git a/src/lean_spec/spec/forks/lstar/containers/attestation.py b/src/lean_spec/spec/forks/lstar/containers/attestation.py index db966c9d5..f7fe6d6f2 100644 --- a/src/lean_spec/spec/forks/lstar/containers/attestation.py +++ b/src/lean_spec/spec/forks/lstar/containers/attestation.py @@ -6,7 +6,7 @@ from lean_spec.spec.forks.lstar.containers.checkpoint import AttestationData from lean_spec.spec.forks.lstar.containers.identifiers import ValidatorIndex from lean_spec.spec.forks.lstar.containers.participation import AggregationBits -from lean_spec.spec.ssz import Container, SSZList +from lean_spec.spec.ssz_types import Container, List class Attestation(Container): @@ -50,7 +50,7 @@ class SignedAggregatedAttestation(Container): """Aggregated single-message proof covering all participating validators.""" -class AggregatedAttestations(SSZList[AggregatedAttestation]): +class AggregatedAttestations(List[AggregatedAttestation]): """List of aggregated attestations included in a block.""" LIMIT = int(VALIDATOR_REGISTRY_LIMIT) diff --git a/src/lean_spec/spec/forks/lstar/containers/block.py b/src/lean_spec/spec/forks/lstar/containers/block.py index eb6287957..db21e0651 100644 --- a/src/lean_spec/spec/forks/lstar/containers/block.py +++ b/src/lean_spec/spec/forks/lstar/containers/block.py @@ -4,7 +4,7 @@ from lean_spec.spec.forks.lstar.containers.attestation import AggregatedAttestations from lean_spec.spec.forks.lstar.containers.identifiers import ValidatorIndex from lean_spec.spec.forks.lstar.slot import Slot -from lean_spec.spec.ssz import Bytes32, Container +from lean_spec.spec.ssz_types import Bytes32, Container class BlockBody(Container): diff --git a/src/lean_spec/spec/forks/lstar/containers/checkpoint.py b/src/lean_spec/spec/forks/lstar/containers/checkpoint.py index ffc21ad99..a9a8dfb63 100644 --- a/src/lean_spec/spec/forks/lstar/containers/checkpoint.py +++ b/src/lean_spec/spec/forks/lstar/containers/checkpoint.py @@ -7,8 +7,10 @@ from collections.abc import Sequence +from ssz import ZERO_ROOT + from lean_spec.spec.forks.lstar.slot import Slot -from lean_spec.spec.ssz import ZERO_HASH, Bytes32, Container +from lean_spec.spec.ssz_types import Bytes32, Container class Checkpoint(Container): @@ -67,9 +69,9 @@ def lies_on_chain(self, historical_block_hashes: Sequence[Bytes32]) -> bool: # Empty slots carry the zero hash on the chain. # A vote whose recorded root equals the zero hash is meaningless. if ( - self.source.root == ZERO_HASH - or self.target.root == ZERO_HASH - or self.head.root == ZERO_HASH + self.source.root == ZERO_ROOT + or self.target.root == ZERO_ROOT + or self.head.root == ZERO_ROOT ): return False diff --git a/src/lean_spec/spec/forks/lstar/containers/genesis.py b/src/lean_spec/spec/forks/lstar/containers/genesis.py index fc3760390..015123af8 100644 --- a/src/lean_spec/spec/forks/lstar/containers/genesis.py +++ b/src/lean_spec/spec/forks/lstar/containers/genesis.py @@ -1,6 +1,8 @@ """Chain configuration committed into the consensus state.""" -from lean_spec.spec.ssz import Container, Uint64 +from ssz import Uint64 + +from lean_spec.spec.ssz_types import Container class GenesisConfig(Container): diff --git a/src/lean_spec/spec/forks/lstar/containers/identifiers.py b/src/lean_spec/spec/forks/lstar/containers/identifiers.py index bb88f91cf..017708985 100644 --- a/src/lean_spec/spec/forks/lstar/containers/identifiers.py +++ b/src/lean_spec/spec/forks/lstar/containers/identifiers.py @@ -1,9 +1,11 @@ """Scalar identifiers naming validators, subnets, and the registry index space.""" +from ssz import Uint64 + from lean_spec.spec.forks.lstar.config import VALIDATOR_REGISTRY_LIMIT from lean_spec.spec.forks.lstar.errors import RejectionReason, SpecRejectionError from lean_spec.spec.forks.lstar.slot import Slot -from lean_spec.spec.ssz import SSZList, Uint64 +from lean_spec.spec.ssz_types import List class SubnetId(Uint64): @@ -49,7 +51,7 @@ def compute_subnet_id(self, num_committees: Uint64) -> SubnetId: return SubnetId(int(self) % int(num_committees)) -class ValidatorIndices(SSZList[ValidatorIndex]): +class ValidatorIndices(List[ValidatorIndex]): """List of validator indices up to the registry limit.""" LIMIT = int(VALIDATOR_REGISTRY_LIMIT) diff --git a/src/lean_spec/spec/forks/lstar/containers/interval.py b/src/lean_spec/spec/forks/lstar/containers/interval.py index 645145834..101df0340 100644 --- a/src/lean_spec/spec/forks/lstar/containers/interval.py +++ b/src/lean_spec/spec/forks/lstar/containers/interval.py @@ -1,8 +1,9 @@ """Interval time unit for the Lean consensus specification.""" +from ssz import Uint64 + from lean_spec.spec.forks.lstar.config import INTERVALS_PER_SLOT from lean_spec.spec.forks.lstar.slot import Slot -from lean_spec.spec.ssz import Uint64 class Interval(Uint64): diff --git a/src/lean_spec/spec/forks/lstar/containers/participation.py b/src/lean_spec/spec/forks/lstar/containers/participation.py index f8134f8de..48fb2323b 100644 --- a/src/lean_spec/spec/forks/lstar/containers/participation.py +++ b/src/lean_spec/spec/forks/lstar/containers/participation.py @@ -2,14 +2,15 @@ from collections.abc import Iterable +from ssz import Boolean + from lean_spec.spec.forks.lstar.config import VALIDATOR_REGISTRY_LIMIT from lean_spec.spec.forks.lstar.containers.identifiers import ValidatorIndex, ValidatorIndices from lean_spec.spec.forks.lstar.errors import RejectionReason, SpecRejectionError -from lean_spec.spec.ssz import Boolean -from lean_spec.spec.ssz.bitfields import BaseBitlist +from lean_spec.spec.ssz_types import BitList -class AggregationBits(BaseBitlist): +class AggregationBits(BitList): """Bitlist representing validator participation in an attestation or signature.""" LIMIT = int(VALIDATOR_REGISTRY_LIMIT) diff --git a/src/lean_spec/spec/forks/lstar/containers/state.py b/src/lean_spec/spec/forks/lstar/containers/state.py index f2246a5e7..efd5491a0 100644 --- a/src/lean_spec/spec/forks/lstar/containers/state.py +++ b/src/lean_spec/spec/forks/lstar/containers/state.py @@ -2,6 +2,8 @@ from typing import Self +from ssz import Boolean + from lean_spec.spec.forks.lstar.config import HISTORICAL_ROOTS_LIMIT, VALIDATOR_REGISTRY_LIMIT from lean_spec.spec.forks.lstar.containers.block import BlockHeader from lean_spec.spec.forks.lstar.containers.checkpoint import Checkpoint @@ -9,23 +11,22 @@ from lean_spec.spec.forks.lstar.containers.validator import Validators from lean_spec.spec.forks.lstar.errors import RejectionReason, SpecRejectionError from lean_spec.spec.forks.lstar.slot import Slot -from lean_spec.spec.ssz import Boolean, Bytes32, Container, SSZList -from lean_spec.spec.ssz.bitfields import BaseBitlist +from lean_spec.spec.ssz_types import BitList, Bytes32, Container, List -class HistoricalBlockHashes(SSZList[Bytes32]): +class HistoricalBlockHashes(List[Bytes32]): """List of historical block root hashes up to historical roots limit.""" LIMIT = int(HISTORICAL_ROOTS_LIMIT) -class JustificationRoots(SSZList[Bytes32]): +class JustificationRoots(List[Bytes32]): """List of justified block roots up to historical roots limit.""" LIMIT = int(HISTORICAL_ROOTS_LIMIT) -class JustifiedSlots(BaseBitlist): +class JustifiedSlots(BitList): """Bitlist tracking justified slots up to historical roots limit.""" LIMIT = int(HISTORICAL_ROOTS_LIMIT) @@ -103,7 +104,7 @@ def extend_to_slot(self, finalized_slot: Slot, target_slot: Slot) -> Self: return type(self)(data=list(self.data) + [Boolean(False)] * gap_size) -class JustificationValidators(BaseBitlist): +class JustificationValidators(BitList): """Per-root validator vote bitfields, concatenated into one flat bitlist.""" LIMIT = int(HISTORICAL_ROOTS_LIMIT) * int(VALIDATOR_REGISTRY_LIMIT) diff --git a/src/lean_spec/spec/forks/lstar/containers/store.py b/src/lean_spec/spec/forks/lstar/containers/store.py index d296445ce..2bb1fdf4e 100644 --- a/src/lean_spec/spec/forks/lstar/containers/store.py +++ b/src/lean_spec/spec/forks/lstar/containers/store.py @@ -11,7 +11,7 @@ from lean_spec.spec.forks.lstar.containers.genesis import GenesisConfig from lean_spec.spec.forks.lstar.containers.identifiers import ValidatorIndex from lean_spec.spec.forks.lstar.containers.interval import Interval -from lean_spec.spec.ssz import Bytes32, Container +from lean_spec.spec.ssz_types import Bytes32, Container class AttestationSignatureEntry(NamedTuple): diff --git a/src/lean_spec/spec/forks/lstar/containers/validator.py b/src/lean_spec/spec/forks/lstar/containers/validator.py index 4d798d70f..ebdbb8650 100644 --- a/src/lean_spec/spec/forks/lstar/containers/validator.py +++ b/src/lean_spec/spec/forks/lstar/containers/validator.py @@ -1,13 +1,12 @@ """The validator registry tracked in the consensus state.""" -from typing import Self +from typing import IO, Self, cast, override from pydantic import model_validator from lean_spec.spec.forks.lstar.config import VALIDATOR_REGISTRY_LIMIT from lean_spec.spec.forks.lstar.containers.identifiers import ValidatorIndex -from lean_spec.spec.ssz import Bytes52, Container, SSZList -from lean_spec.spec.ssz.exceptions import SSZValueError +from lean_spec.spec.ssz_types import Bytes52, Container, ContainerInvariantError, List class Validator(Container): @@ -23,19 +22,31 @@ class Validator(Container): """Validator index in the registry.""" -class Validators(SSZList[Validator]): +class Validators(List[Validator]): """Validator registry tracked in the state.""" LIMIT = int(VALIDATOR_REGISTRY_LIMIT) @model_validator(mode="after") - def _require_index_matches_position(self) -> Self: + def _check_index_matches_position(self) -> Self: """Reject any registry whose stored validator indices disagree with their positions.""" + self._require_index_matches_position() + return self + + def _require_index_matches_position(self) -> None: + """Refuse a registry whose stored validator indices disagree with their positions.""" for registry_position, validator in enumerate(self.data): if int(validator.index) != registry_position: - raise SSZValueError( + raise ContainerInvariantError( f"validator at position {registry_position} has " f"index {int(validator.index)}, " f"but the registry index must equal the list position" ) - return self + + @classmethod + @override + def deserialize(cls, stream: IO[bytes], scope: int) -> Self: + """Read a registry, then re-check the rule the sequence decoder builds past.""" + registry = cast(Self, super().deserialize(stream, scope)) + registry._require_index_matches_position() + return registry diff --git a/src/lean_spec/spec/forks/lstar/fork_choice.py b/src/lean_spec/spec/forks/lstar/fork_choice.py index f2c983ae5..8a14c239b 100644 --- a/src/lean_spec/spec/forks/lstar/fork_choice.py +++ b/src/lean_spec/spec/forks/lstar/fork_choice.py @@ -3,7 +3,8 @@ import math from collections import defaultdict -from lean_spec.spec.crypto.merkleization import hash_tree_root +from ssz import Uint64, hash_tree_root + from lean_spec.spec.crypto.xmss.containers import PublicKey from lean_spec.spec.crypto.xmss.interface import TARGET_SIGNATURE_SCHEME from lean_spec.spec.forks.lstar._base import LstarSpecBase, LstarStore @@ -33,7 +34,7 @@ observe_on_attestation, observe_on_block, ) -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 class ForkChoiceMixin(LstarSpecBase): diff --git a/src/lean_spec/spec/forks/lstar/signatures.py b/src/lean_spec/spec/forks/lstar/signatures.py index 10728e61e..6b8c76d8f 100644 --- a/src/lean_spec/spec/forks/lstar/signatures.py +++ b/src/lean_spec/spec/forks/lstar/signatures.py @@ -1,6 +1,7 @@ """Lstar fork — block signature verification.""" -from lean_spec.spec.crypto.merkleization import hash_tree_root +from ssz import Uint64, hash_tree_root + from lean_spec.spec.crypto.xmss.containers import PublicKey from lean_spec.spec.forks.lstar._base import LstarSpecBase from lean_spec.spec.forks.lstar.containers import ( @@ -10,7 +11,7 @@ Validators, ) from lean_spec.spec.forks.lstar.errors import RejectionReason, SpecRejectionError -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 class SignatureMixin(LstarSpecBase): diff --git a/src/lean_spec/spec/forks/lstar/slot.py b/src/lean_spec/spec/forks/lstar/slot.py index c792f5650..479591d35 100644 --- a/src/lean_spec/spec/forks/lstar/slot.py +++ b/src/lean_spec/spec/forks/lstar/slot.py @@ -5,7 +5,7 @@ import math from typing import Final -from lean_spec.spec.ssz import Uint64 +from ssz import Uint64 IMMEDIATE_JUSTIFICATION_WINDOW: Final = 5 """First N slots after finalization are always justifiable.""" diff --git a/src/lean_spec/spec/forks/lstar/state_transition.py b/src/lean_spec/spec/forks/lstar/state_transition.py index 58ec063bc..065465d69 100644 --- a/src/lean_spec/spec/forks/lstar/state_transition.py +++ b/src/lean_spec/spec/forks/lstar/state_transition.py @@ -3,7 +3,8 @@ from collections.abc import Iterable from itertools import batched -from lean_spec.spec.crypto.merkleization import hash_tree_root +from ssz import ZERO_ROOT, Boolean, Uint64, hash_tree_root + from lean_spec.spec.forks.lstar._base import LstarSpecBase from lean_spec.spec.forks.lstar.config import MAX_ATTESTATIONS_DATA from lean_spec.spec.forks.lstar.containers import ( @@ -23,7 +24,7 @@ from lean_spec.spec.observability import ( observe_state_transition, ) -from lean_spec.spec.ssz import ZERO_HASH, Boolean, Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 class StateTransitionMixin(LstarSpecBase): @@ -145,7 +146,7 @@ def process_block_header(self, state: State, block: Block) -> State: # Record the parent root, then a zero hash for each skipped slot. new_historical_block_hashes = ( - state.historical_block_hashes + [parent_root] + [ZERO_HASH] * num_empty_slots + state.historical_block_hashes + [parent_root] + [ZERO_ROOT] * num_empty_slots ) # The justified-slot flags are stored relative to the finalized boundary. @@ -250,7 +251,7 @@ def process_attestations( ) # The zero hash marks a skipped slot, never a real block, so it cannot track votes. - if any(root == ZERO_HASH for root in state.justifications_roots): + if any(root == ZERO_ROOT for root in state.justifications_roots): raise SpecRejectionError( RejectionReason.ZERO_HASH_JUSTIFICATION_ROOT, "Tracked justification roots contain the zero hash", diff --git a/src/lean_spec/spec/forks/lstar/validator_duties.py b/src/lean_spec/spec/forks/lstar/validator_duties.py index 4b6520ade..7bd35d043 100644 --- a/src/lean_spec/spec/forks/lstar/validator_duties.py +++ b/src/lean_spec/spec/forks/lstar/validator_duties.py @@ -1,6 +1,7 @@ """Lstar fork — validator duties: proposal head and production.""" -from lean_spec.spec.crypto.merkleization import hash_tree_root +from ssz import Uint64, hash_tree_root + from lean_spec.spec.forks.lstar._base import LstarSpecBase, LstarStore from lean_spec.spec.forks.lstar.config import ( JUSTIFICATION_LOOKBACK_SLOTS, @@ -15,7 +16,7 @@ ValidatorIndex, ) from lean_spec.spec.forks.lstar.errors import RejectionReason, SpecRejectionError -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 class ValidatorDutiesMixin(LstarSpecBase): diff --git a/src/lean_spec/spec/forks/protocol.py b/src/lean_spec/spec/forks/protocol.py index d64e67186..a3b9391ed 100644 --- a/src/lean_spec/spec/forks/protocol.py +++ b/src/lean_spec/spec/forks/protocol.py @@ -11,7 +11,7 @@ from typing import ClassVar, Protocol, Self from lean_spec.spec.forks.lstar.containers import Checkpoint, Slot, ValidatorIndex -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 class SpecSSZType(Protocol): diff --git a/src/lean_spec/spec/ssz/__init__.py b/src/lean_spec/spec/ssz/__init__.py deleted file mode 100644 index 1747575a6..000000000 --- a/src/lean_spec/spec/ssz/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -"""SSZ primitive types and (de)serialization for the Lean Ethereum specification.""" - -from lean_spec.spec.ssz.bitfields import BaseBitlist, BaseBitvector -from lean_spec.spec.ssz.boolean import Boolean -from lean_spec.spec.ssz.byte_arrays import ( - ZERO_HASH, - BaseByteList, - BaseBytes, - ByteList512KiB, - Bytes4, - Bytes16, - Bytes20, - Bytes32, - Bytes33, - Bytes52, - Bytes64, -) -from lean_spec.spec.ssz.collections import SSZList, SSZVector -from lean_spec.spec.ssz.container import Container -from lean_spec.spec.ssz.exceptions import ( - SSZError, - SSZSerializationError, - SSZTypeError, - SSZValueError, -) -from lean_spec.spec.ssz.ssz_base import SSZType -from lean_spec.spec.ssz.uint import Uint8, Uint16, Uint32, Uint64 - -__all__ = [ - "ZERO_HASH", - "BaseBitlist", - "BaseBitvector", - "BaseByteList", - "BaseBytes", - "Boolean", - "ByteList512KiB", - "Bytes4", - "Bytes16", - "Bytes20", - "Bytes32", - "Bytes33", - "Bytes52", - "Bytes64", - "Container", - "SSZError", - "SSZList", - "SSZSerializationError", - "SSZType", - "SSZTypeError", - "SSZValueError", - "SSZVector", - "Uint8", - "Uint16", - "Uint32", - "Uint64", -] diff --git a/src/lean_spec/spec/ssz/bitfields.py b/src/lean_spec/spec/ssz/bitfields.py deleted file mode 100644 index 6355908b4..000000000 --- a/src/lean_spec/spec/ssz/bitfields.py +++ /dev/null @@ -1,444 +0,0 @@ -""" -SSZ bitfield types. - -A bitfield is a packed sequence of booleans serialized to bytes. - -Two flavors are defined by the SSZ spec: - -- Fixed-length: exactly N bits encoded in ceil(N / 8) bytes. -- Variable-length: 0 to N bits encoded with a trailing delimiter bit that marks the end. - -Both flavors pack bits little-endian within each byte. -Bit i of the input lands in byte i // 8 at position i % 8. -""" - -import math -from collections.abc import Sequence -from typing import ( - IO, - Any, - ClassVar, - Self, - overload, - override, -) - -from pydantic import Field, field_validator - -from lean_spec.spec.ssz.boolean import Boolean -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError, SSZValueError -from lean_spec.spec.ssz.ssz_base import SSZModel - - -class BaseBitvector(SSZModel): - """ - Fixed-length SSZ bitfield with exactly N bits. - - - Subclasses pin the bit count by setting the class-level length. - - Serialization packs bits little-endian into ceil(N / 8) bytes. - - Trailing bits in the last byte are zero when N is not a multiple of 8. - - For example, [1, 1, 1, 1, 1] (5 bits, all set) encodes to a single byte. - list[i] lands at bit i, where bit 0 is the LSB (rightmost in the byte): - - bit position: 7 6 5 4 3 2 1 0 - byte 0: 0 0 0 1 1 1 1 1 -> 0b00011111 - - Bits 5, 6, 7 are trailing zeros — only the lowest 5 hold data. - """ - - LENGTH: ClassVar[int] - """Number of bits in the vector.""" - - data: Sequence[Boolean] = Field(default_factory=tuple) - """ - The immutable bit data stored as a sequence of booleans. - - Accepts lists, tuples, or iterables of bool-like values on input. - Stored as an immutable tuple after validation. - """ - - @field_validator("data", mode="before") - @classmethod - def _coerce_and_validate(cls, bits_input: Any) -> tuple[Boolean, ...]: - """Enforce the exact bit count and coerce inputs into booleans.""" - # Subclasses must declare LENGTH before any instances can be validated. - if not hasattr(cls, "LENGTH"): - raise SSZTypeError(f"{cls.__name__} must define LENGTH") - - # Materialize generic iterables into a tuple so the length check works. - if not isinstance(bits_input, (list, tuple)): - bits_input = tuple(bits_input) - - # Fixed-length type: the input must contain exactly LENGTH elements. - if len(bits_input) != cls.LENGTH: - raise SSZValueError( - f"{cls.__name__} requires exactly {cls.LENGTH} elements, got {len(bits_input)}" - ) - - # Wrap each value in Boolean — the constructor rejects anything outside 0 or 1. - return tuple(Boolean(bit) for bit in bits_input) - - @classmethod - @override - def is_fixed_size(cls) -> bool: - """Always fixed-size by definition.""" - return True - - @classmethod - @override - def get_byte_length(cls) -> int: - """Return the number of bytes needed to pack the bits.""" - return math.ceil(cls.LENGTH / 8) - - @override - def serialize(self, stream: IO[bytes]) -> int: - """Write SSZ bytes to a binary stream.""" - encoded_data = self.encode_bytes() - stream.write(encoded_data) - return len(encoded_data) - - @classmethod - @override - def deserialize(cls, stream: IO[bytes], scope: int) -> Self: - """Read SSZ bytes from a stream and return an instance.""" - expected_byte_count = cls.get_byte_length() - if scope != expected_byte_count: - raise SSZSerializationError( - f"{cls.__name__}: expected {expected_byte_count} bytes, got {scope}" - ) - serialized_bytes = stream.read(scope) - if len(serialized_bytes) != scope: - raise SSZSerializationError( - f"{cls.__name__}: expected {scope} bytes, got {len(serialized_bytes)}" - ) - return cls.decode_bytes(serialized_bytes) - - @override - def encode_bytes(self) -> bytes: - """ - Encode the bitfield to SSZ bytes. - - Bits are packed little-endian within each byte. - Bit i of the input lands in byte i // 8 at position i % 8. - - Returns: - ceil(N / 8) bytes containing the packed bits. - """ - # Build the packed bits as one integer, then split into little-endian bytes. - packed_bits = sum(1 << i for i, bit in enumerate(self.data) if bit) - return packed_bits.to_bytes(self.get_byte_length(), "little") - - @classmethod - @override - def decode_bytes(cls, data: bytes) -> Self: - """ - Decode SSZ bytes into a bitfield. - - Input must be exactly ceil(N / 8) bytes. - Fixed-length bitfields carry no delimiter — the byte count alone is enough to recover N. - - Args: - data: SSZ-encoded bytes with the packed bits. - - Returns: - A bitfield instance with N bits read from the input. - - Raises: - SSZValueError: If the input length does not match the expected byte count. - SSZValueError: If any padding bit above the last data bit is set. - """ - # Reject inputs whose byte count does not match the expected size. - expected_byte_count = cls.get_byte_length() - if len(data) != expected_byte_count: - raise SSZValueError( - f"{cls.__name__}: expected {expected_byte_count} bytes, got {len(data)}" - ) - - # When the bit count is not a multiple of 8, the last byte holds padding - # bits above the highest data bit. - # SSZ requires those padding bits to be zero so the encoding is canonical. - # Without this check, 0b00011111 and 0b11111111 both decode to a 5-bit - # vector of all ones. - if trailing_bit_count := cls.LENGTH % 8: - if data[-1] >> trailing_bit_count: - raise SSZValueError( - f"{cls.__name__}: non-zero padding bits in final byte {data[-1]:#04x}" - ) - - # Read every bit position out of the byte stream. - # - # For bit index i: - # - # - data[i // 8] picks the byte that holds bit i. - # - >> (i % 8) shifts that byte so bit i is in the LSB. - # - & 1 masks off every other bit. - # - # Example: data = [0b00000101, 0b00000001] (encoding of 9 bits, 2 bytes) - # - # i=0: (data[0] >> 0) & 1 = 0b00000101 & 1 = 1 - # i=1: (data[0] >> 1) & 1 = 0b00000010 & 1 = 0 - # i=2: (data[0] >> 2) & 1 = 0b00000001 & 1 = 1 - # i=3: (data[0] >> 3) & 1 = 0b00000000 & 1 = 0 - # ... - # i=7: (data[0] >> 7) & 1 = 0b00000000 & 1 = 0 - # i=8: (data[1] >> 0) & 1 = 0b00000001 & 1 = 1 - # - # Recovered bits: [1, 0, 1, 0, 0, 0, 0, 0, 1] - return cls(data=[Boolean((data[i // 8] >> (i % 8)) & 1) for i in range(cls.LENGTH)]) - - -class BaseBitlist(SSZModel): - """ - Variable-length SSZ bitfield with 0 to N bits. - - - Subclasses pin the maximum bit count by setting the class-level limit. - - Serialization packs data bits little-endian, then appends a single 1 bit as a delimiter. - - The delimiter is what lets the decoder recover the original bit count. - - For example, [1, 0, 1] (3 data bits) encodes to a single byte. - - list[i] lands at bit i, where bit 0 is the LSB (rightmost in the byte): - - bit position: 7 6 5 4 3 2 1 0 - byte 0: 0 0 0 0 [1] 1 0 1 -> 0b00001101 (bracketed bit is the delimiter) - - Without the delimiter, two different lists would collide: - - [1, 0, 1] -> 0b00000101 - [1, 0, 1, 0, 0, 0, 0, 0] -> 0b00000101 - """ - - LIMIT: ClassVar[int] - """Maximum number of bits allowed.""" - - data: Sequence[Boolean] = Field(default_factory=tuple) - """ - The immutable bit data stored as a sequence of booleans. - - Accepts lists, tuples, or iterables of bool-like values on input. - Stored as an immutable tuple after validation. - """ - - @field_validator("data", mode="before") - @classmethod - def _coerce_and_validate(cls, bits_input: Any) -> tuple[Boolean, ...]: - """Enforce the maximum bit count and coerce inputs into booleans.""" - # Subclasses must declare LIMIT before any instances can be validated. - if not hasattr(cls, "LIMIT"): - raise SSZTypeError(f"{cls.__name__} must define LIMIT") - - # Accept different input shapes: - # - # - list or tuple pass through directly. - # - other iterables materialize into a list so length is known. - # - str or bytes rejected — iterable but elements are not booleans. - if isinstance(bits_input, (list, tuple)): - elements = bits_input - elif hasattr(bits_input, "__iter__") and not isinstance(bits_input, (str, bytes)): - elements = list(bits_input) - else: - raise SSZTypeError(f"Expected iterable, got {type(bits_input).__name__}") - - # Variable-length type: any count is fine, up to LIMIT. - if len(elements) > cls.LIMIT: - raise SSZValueError(f"{cls.__name__} exceeds limit of {cls.LIMIT}, got {len(elements)}") - - # Wrap each value in Boolean — the constructor rejects anything outside 0 or 1. - return tuple(Boolean(bit) for bit in elements) - - @overload - def __getitem__(self, key: int) -> Boolean: ... - - @overload - def __getitem__(self, key: slice) -> list[Boolean]: ... - - def __getitem__(self, key: int | slice) -> Boolean | list[Boolean]: - """Get a bit by index or slice.""" - if isinstance(key, slice): - return list(self.data[key]) - return self.data[key] - - def __add__(self, other: Any) -> Self: - """Concatenate with another bit sequence.""" - if isinstance(other, BaseBitlist): - new_data = (*self.data, *other.data) - elif isinstance(other, (list, tuple)): - new_data = (*self.data, *(Boolean(b) for b in other)) - else: - return NotImplemented - return type(self)(data=new_data) - - @classmethod - @override - def is_fixed_size(cls) -> bool: - """Variable-size by definition — the bit count ranges from zero to the class limit.""" - return False - - @classmethod - @override - def get_byte_length(cls) -> int: - """ - Variable-size types have no fixed byte length. - - Raises: - SSZTypeError: Always — call this only on fixed-size types. - """ - raise SSZTypeError(f"{cls.__name__}: variable-size bitlist has no fixed byte length") - - @override - def serialize(self, stream: IO[bytes]) -> int: - """Write SSZ bytes to a binary stream.""" - encoded_data = self.encode_bytes() - stream.write(encoded_data) - return len(encoded_data) - - @classmethod - @override - def deserialize(cls, stream: IO[bytes], scope: int) -> Self: - """Read SSZ bytes from a stream and return an instance.""" - serialized_bytes = stream.read(scope) - if len(serialized_bytes) != scope: - raise SSZSerializationError( - f"{cls.__name__}: expected {scope} bytes, got {len(serialized_bytes)}" - ) - return cls.decode_bytes(serialized_bytes) - - @override - def encode_bytes(self) -> bytes: - """ - Encode the bitlist to SSZ bytes with a trailing delimiter. - - # Overview - - Data bits are packed little-endian within each byte. - A single 1 bit is placed immediately after the last data bit. - The trailing bit is what lets the decoder recover the original count. - - # Delimiter - - SSZ encodes bitlists as raw bytes with no length prefix. - Without a marker, [1, 0] and [1, 0, 0, 0, 0, 0, 0, 0] would share the byte 0x01. - A trailing 1 bit is the smallest sentinel that disambiguates them. - - # Layout - - bits = [1, 0, 1] -> byte 0: 0 0 0 0 [1] 1 0 1 (delimiter at bit 3) - - bits = [1] * 8 -> byte 0: 1 1 1 1 1 1 1 1 - byte 1: 0 0 0 0 0 0 0 [1] (delimiter spills) - - Returns: - SSZ bytes containing the data bits followed by the delimiter. - """ - # Empty bitlist still needs the delimiter byte. - num_bits = len(self.data) - if num_bits == 0: - return b"\x01" - - # Pack data bits and the delimiter into one integer. - # The trailing 1 sentinel sits at the bit just past the data. - # It is what lets the decoder recover the original bit count. - # Converting an integer to bytes runs the bit-to-byte split in C. - # That avoids a Python loop over every bit. - packed_bits = sum(1 << i for i, bit in enumerate(self.data) if bit) | (1 << num_bits) - return packed_bits.to_bytes(math.ceil((num_bits + 1) / 8), "little") - - @classmethod - @override - def decode_bytes(cls, data: bytes) -> Self: - """ - Decode SSZ bytes into a bitlist by locating the delimiter bit. - - # Overview - - - The highest set bit in the input is the delimiter. - - Bits below it are data. - - Bits above it are zero padding. - - Empty input is invalid (the empty bitlist still encodes as one byte, 0x01). - - # Integer interpretation - - Reading the byte stream as a little-endian integer aligns bits and bytes perfectly: - - byte 0 bit j -> integer bit j - byte 1 bit j -> integer bit (8 + j) - byte k bit j -> integer bit (8 * k + j) - - For example, data = [0b00000101, 0b00000010]: - - int.from_bytes(data, "little") = 0b1000000101 - - byte 0 bit 0 (=1) -> integer bit 0 - byte 0 bit 2 (=1) -> integer bit 2 - byte 1 bit 1 (=1) -> integer bit 9 (= 8 * 1 + 1) - - The highest set bit of the integer is exactly the delimiter position. - - Args: - data: SSZ-encoded bytes containing data bits followed by a single 1 delimiter. - - Returns: - A bitlist instance with the recovered data bits. - - Raises: - SSZSerializationError: If the input is empty or contains no 1 bits. - SSZValueError: If the recovered bit count exceeds the class limit. - """ - # Phase 1: reject empty input. - # - # The empty bitlist still encodes to one byte (0x01). - if len(data) == 0: - raise SSZSerializationError(f"{cls.__name__}: cannot decode empty bytes") - - # Phase 2: locate the delimiter — the topmost 1 in the entire byte stream. - # - # - int.from_bytes(data, "little") reads the stream as one little-endian integer. - # - bit_length() 1-indexed position of its highest set bit. - # - bit_length() - 1 0-indexed delimiter position in the stream. - # - # Example A: data = [0b00001101] (encoding of bits [1, 0, 1]) - # - # int.from_bytes(data, "little") = 13 = 0b00001101 - # bit_length() = 4 - # delimiter_pos = 3 -> num_bits = 3 - # - # Example B: data = [0b11111111, 0b00000001] (encoding of bits [1] * 8) - # - # int.from_bytes(data, "little") = 511 = 0b111111111 - # bit_length() = 9 - # delimiter_pos = 8 -> num_bits = 8 - packed_integer = int.from_bytes(data, "little") - if packed_integer == 0: - raise SSZSerializationError(f"{cls.__name__}: no delimiter bit found") - delimiter_pos = packed_integer.bit_length() - 1 - - # The delimiter must sit in the final byte of the input. - # Reading the stream as one integer silently drops trailing zero bytes. - # So a canonical encoding and one with extra zero bytes decode the same. - # Rejecting the padded form keeps a single valid encoding per value. - if delimiter_pos // 8 != len(data) - 1: - raise SSZSerializationError( - f"{cls.__name__}: non-canonical trailing zero bytes after delimiter" - ) - - # Phase 3: extract data bits below the delimiter and enforce the size limit. - # - # The delimiter position equals the data bit count. For each bit index i: - # - # - data[i // 8] picks the byte that holds bit i. - # - >> (i % 8) shifts that byte so bit i is in the LSB. - # - & 1 masks off every other bit. - # - # Example: data = [0b00001101], num_bits = 3 (delimiter at bit 3) - # - # i=0: (data[0] >> 0) & 1 = 0b00001101 & 1 = 1 - # i=1: (data[0] >> 1) & 1 = 0b00000110 & 1 = 0 - # i=2: (data[0] >> 2) & 1 = 0b00000011 & 1 = 1 - # - # Recovered bits: [1, 0, 1] - num_bits = delimiter_pos - if num_bits > cls.LIMIT: - raise SSZValueError(f"{cls.__name__} exceeds limit of {cls.LIMIT}, got {num_bits}") - - return cls(data=[Boolean((data[i // 8] >> (i % 8)) & 1) for i in range(num_bits)]) diff --git a/src/lean_spec/spec/ssz/boolean.py b/src/lean_spec/spec/ssz/boolean.py deleted file mode 100644 index d9f87e3e0..000000000 --- a/src/lean_spec/spec/ssz/boolean.py +++ /dev/null @@ -1,245 +0,0 @@ -"""SSZ boolean type — true or false serialized as a single byte.""" - -from __future__ import annotations - -from typing import IO, Any, NoReturn, Self, override - -from pydantic.annotated_handlers import GetCoreSchemaHandler -from pydantic_core import CoreSchema, core_schema - -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError, SSZValueError -from lean_spec.spec.ssz.ssz_base import SSZType - - -class Boolean(int, SSZType): - r""" - Strict SSZ boolean encoded as exactly one byte. - - - Inherits from int so true/false work natively in truthiness checks. - - Arithmetic (+ - * /) is disabled to prevent ambiguous operations. - - Bitwise ops (& | ^) reject operands of any other type. - - Equality rejects comparisons with anything but another boolean. - - Wire format: - - true -> b"\x01" - false -> b"\x00" - """ - - __slots__ = () - - def __new__(cls, value: bool | int) -> Self: - """ - Construct and validate a new boolean. - - Only the four values true, false, 0, and 1 are accepted. - - Args: - value: The raw value to wrap. - - Raises: - SSZTypeError: If value is not a bool or int. - SSZValueError: If value is an integer outside 0 or 1. - """ - if not isinstance(value, int): - raise SSZTypeError(f"Expected bool or int, got {type(value).__name__}") - - # Coerce to a plain int before the membership test: - # - # - value in (0, 1) does value == 0 or value == 1. - # - For a Boolean operand, those comparisons hit strict equality and raise. - # - int(value) returns a plain int, so == falls back to int equality. - if int(value) not in (0, 1): - raise SSZValueError(f"Boolean value must be 0 or 1, not {value}") - - return super().__new__(cls, value) - - @classmethod - def __get_pydantic_core_schema__( - cls, source_type: Any, handler: GetCoreSchemaHandler - ) -> CoreSchema: - """ - Provide a Pydantic core schema that enforces strict boolean validation. - - Only true or false are accepted as input at the Pydantic layer. - Any other type — including int 0 or 1 — is rejected here, even though - the constructor itself accepts them. - """ - # Validator that wraps a verified bool into a typed instance. - from_bool_validator = core_schema.no_info_plain_validator_function(cls) - - # Two-step input validation: - # - # - bool_schema(strict=True) rejects anything that is not exactly a bool. - # - from_bool_validator wraps the validated bool into a Boolean. - python_schema = core_schema.chain_schema( - [core_schema.bool_schema(strict=True), from_bool_validator] - ) - - # Final schema accepts either branch and serializes back to a plain bool: - # - # - Branch 1: input is already a typed instance, pass through. - # - Branch 2: input is a strict bool that needs wrapping. - return core_schema.union_schema( - [ - core_schema.is_instance_schema(cls), - python_schema, - ], - serialization=core_schema.plain_serializer_function_ser_schema(bool), - ) - - @classmethod - @override - def is_fixed_size(cls) -> bool: - """Always fixed-size — every boolean encodes to one byte.""" - return True - - @classmethod - @override - def get_byte_length(cls) -> int: - """Return the byte length of the encoded form.""" - return 1 - - @override - def encode_bytes(self) -> bytes: - r""" - Encode the boolean to its SSZ byte representation. - - - true -> b"\x01" - - false -> b"\x00" - """ - return b"\x01" if self else b"\x00" - - @classmethod - @override - def decode_bytes(cls, data: bytes) -> Self: - """ - Decode a single SSZ byte into a boolean. - - Input must be exactly one byte with value 0x00 or 0x01. - - Args: - data: SSZ-encoded byte. - - Returns: - A boolean wrapping the decoded value. - - Raises: - SSZSerializationError: - - When the input length is not 1. - - When the byte value is outside the 0x00 / 0x01 set. - """ - if len(data) != 1: - raise SSZSerializationError(f"Boolean: expected 1 byte, got {len(data)}") - if data[0] not in (0, 1): - raise SSZSerializationError(f"Boolean: byte must be 0x00 or 0x01, got {data[0]:#04x}") - return cls(data[0]) - - @override - def serialize(self, stream: IO[bytes]) -> int: - """Write the SSZ-encoded byte to a binary stream.""" - encoded_data = self.encode_bytes() - stream.write(encoded_data) - return len(encoded_data) - - @classmethod - @override - def deserialize(cls, stream: IO[bytes], scope: int) -> Self: - """ - Read one SSZ byte from a stream and decode into a boolean. - - Args: - stream: Source binary stream. - scope: Number of bytes the caller has allocated for this value (must be 1). - - Returns: - A boolean wrapping the decoded value. - - Raises: - SSZSerializationError: - - When scope is not 1. - - When the underlying byte decode fails. - """ - if scope != 1: - raise SSZSerializationError(f"Boolean: expected scope of 1, got {scope}") - return cls.decode_bytes(stream.read(1)) - - def _no_arithmetic(self, other: Any) -> NoReturn: - """Reject arithmetic on Boolean — use bitwise & | ^ instead.""" - raise TypeError("Arithmetic operations are not supported for Boolean.") - - __add__ = __radd__ = __sub__ = __rsub__ = _no_arithmetic - - def __and__(self, other: Any) -> Self: - """Bitwise AND between two booleans — rejects any other operand.""" - if not isinstance(other, type(self)): - raise TypeError( - f"Unsupported operand type(s) for &: " - f"'{type(self).__name__}' and '{type(other).__name__}'" - ) - return type(self)(int(self) & int(other)) - - def __rand__(self, other: Any) -> Self: - """Bitwise AND when the boolean is on the right of the operator.""" - return self.__and__(other) - - def __or__(self, other: Any) -> Self: - """Bitwise OR between two booleans — rejects any other operand.""" - if not isinstance(other, type(self)): - raise TypeError( - f"Unsupported operand type(s) for |: " - f"'{type(self).__name__}' and '{type(other).__name__}'" - ) - return type(self)(int(self) | int(other)) - - def __ror__(self, other: Any) -> Self: - """Bitwise OR when the boolean is on the right of the operator.""" - return self.__or__(other) - - def __xor__(self, other: Any) -> Self: - """Bitwise XOR between two booleans — rejects any other operand.""" - if not isinstance(other, type(self)): - raise TypeError( - f"Unsupported operand type(s) for ^: " - f"'{type(self).__name__}' and '{type(other).__name__}'" - ) - return type(self)(int(self) ^ int(other)) - - def __rxor__(self, other: Any) -> Self: - """Bitwise XOR when the boolean is on the right of the operator.""" - return self.__xor__(other) - - def __eq__(self, other: object) -> bool: - """Strict equality — only another boolean compares; anything else raises.""" - if not isinstance(other, Boolean): - raise TypeError( - f"Unsupported operand type(s) for ==: " - f"'{type(self).__name__}' and '{type(other).__name__}'" - ) - return int(self) == int(other) - - def __ne__(self, other: object) -> bool: - """ - Strict inequality — only another boolean compares; anything else raises. - - Defined explicitly because the parent class's not-equal would otherwise - bypass the strict equality above. - """ - if not isinstance(other, Boolean): - raise TypeError( - f"Unsupported operand type(s) for !=: " - f"'{type(self).__name__}' and '{type(other).__name__}'" - ) - return int(self) != int(other) - - def __repr__(self) -> str: - """Return the official form: Boolean(True) or Boolean(False).""" - return f"Boolean({bool(self)})" - - def __str__(self) -> str: - """Return the user-facing form: True or False.""" - return str(bool(self)) - - def __hash__(self) -> int: - """Return a hash distinct from the equivalent raw bool, matching strict equality.""" - return hash((type(self), int(self))) diff --git a/src/lean_spec/spec/ssz/byte_arrays.py b/src/lean_spec/spec/ssz/byte_arrays.py deleted file mode 100644 index b3ca8de09..000000000 --- a/src/lean_spec/spec/ssz/byte_arrays.py +++ /dev/null @@ -1,443 +0,0 @@ -""" -SSZ byte array types. - -A byte array is a contiguous sequence of bytes serialized directly to the wire. - -Two flavors are defined by the SSZ spec: - -- Fixed-length: exactly N bytes — the byte count is part of the type. -- Variable-length: 0 to N bytes — the byte count is recovered from the surrounding context. - -Both flavors serialize as the raw bytes themselves — no length prefix, no delimiter. -""" - -from collections.abc import Iterable -from typing import IO, Any, ClassVar, Self, override - -from pydantic import Field, field_serializer, field_validator -from pydantic.annotated_handlers import GetCoreSchemaHandler -from pydantic_core import core_schema - -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError, SSZValueError -from lean_spec.spec.ssz.ssz_base import SSZModel, SSZType - - -class BaseBytes(bytes, SSZType): - r""" - Fixed-length SSZ byte array with exactly N bytes. - - - Inherits from bytes so the instance is usable wherever a bytes value is expected. - - Subclasses pin the byte count by setting the class-level length. - - Equality is strict — only another byte-array instance compares. - - For example, Bytes4 wraps four raw bytes and serializes verbatim: - - Bytes4(b"\x01\x02\x03\x04") -> wire bytes 01 02 03 04 - """ - - __slots__ = () - - LENGTH: ClassVar[int] - """The exact number of bytes (overridden by subclasses).""" - - @staticmethod - def _coerce_to_bytes(value: bytes | bytearray | str | Iterable[int]) -> bytes: - """ - Coerce an input into a plain bytes object. - - Accepts: - - - bytes or bytearray — returned as an immutable bytes copy. - - Iterables of integers in 0..255. - - Hex strings, optionally prefixed with 0x. - - Args: - value: The raw input to convert. - - Returns: - The coerced bytes. - - Raises: - TypeError: If the input type is not coercible. - ValueError: If a hex string is malformed or an integer is out of range. - """ - match value: - case bytes() | bytearray(): - return bytes(value) - case str(): - return bytes.fromhex(value.removeprefix("0x")) - case Iterable(): - return bytes(value) - case _: - raise TypeError(f"Cannot coerce {type(value).__name__} to bytes") - - def __new__(cls, value: bytes | bytearray | str | Iterable[int] = b"") -> Self: - """ - Construct and validate a new byte array. - - Args: - value: Any input coercible to bytes — bytes, bytearray, iterable of ints, or hex string. - - Raises: - SSZTypeError: If the subclass has not declared a length. - SSZValueError: If the coerced byte count differs from the declared length. - """ - if not hasattr(cls, "LENGTH"): - raise SSZTypeError(f"{cls.__name__} must define LENGTH") - - coerced_bytes = cls._coerce_to_bytes(value) - if len(coerced_bytes) != cls.LENGTH: - raise SSZValueError( - f"{cls.__name__} requires exactly {cls.LENGTH} bytes, got {len(coerced_bytes)}" - ) - return super().__new__(cls, coerced_bytes) - - @classmethod - def zero(cls) -> Self: - """Return a new instance filled with zero bytes.""" - return cls(b"\x00" * cls.LENGTH) - - @classmethod - @override - def is_fixed_size(cls) -> bool: - """Always fixed-size by definition.""" - return True - - @classmethod - @override - def get_byte_length(cls) -> int: - """Return the declared byte length.""" - return cls.LENGTH - - @override - def serialize(self, stream: IO[bytes]) -> int: - """Write the raw bytes to a binary stream and return the number of bytes written.""" - stream.write(self) - return len(self) - - @classmethod - @override - def deserialize(cls, stream: IO[bytes], scope: int) -> Self: - """ - Read the declared number of bytes from a stream. - - Args: - stream: Source binary stream. - scope: Number of bytes the caller has allocated for this value (must equal LENGTH). - - Returns: - A new instance wrapping the read bytes. - - Raises: - SSZSerializationError: - - When scope does not equal the declared LENGTH. - - When the stream ends before delivering scope bytes. - """ - if scope != cls.LENGTH: - raise SSZSerializationError(f"{cls.__name__}: expected {cls.LENGTH} bytes, got {scope}") - serialized_bytes = stream.read(scope) - if len(serialized_bytes) != scope: - raise SSZSerializationError( - f"{cls.__name__}: expected {scope} bytes, got {len(serialized_bytes)}" - ) - # Length already verified — bypass __new__'s coerce + revalidation. - return bytes.__new__(cls, serialized_bytes) - - @override - def encode_bytes(self) -> bytes: - """Return the SSZ-encoded bytes as a plain bytes object.""" - return bytes(self) - - @classmethod - @override - def decode_bytes(cls, data: bytes) -> Self: - """Parse SSZ bytes into an instance — the constructor enforces the declared length.""" - return cls(data) - - @classmethod - def __get_pydantic_core_schema__( - cls, source_type: Any, handler: GetCoreSchemaHandler - ) -> core_schema.CoreSchema: - """ - Provide a Pydantic core schema for strict byte-array validation. - - - Already-typed instances pass through. - - Plain bytes inputs go through length-checked validation, then get wrapped. - - Hex string inputs (with an optional 0x prefix) go through the constructor. - - JSON serialization converts the bytes to a 0x-prefixed hex string. - """ - # Shared validator that runs the constructor on a verified input. - # The constructor handles bytes, bytearray, hex strings, or iterables of ints. - # It also enforces the declared length. - from_input_validator = core_schema.no_info_plain_validator_function(cls) - - # Bytes path enforces the exact declared length, then wraps into a typed instance. - bytes_path = core_schema.chain_schema( - [ - core_schema.bytes_schema(min_length=cls.LENGTH, max_length=cls.LENGTH), - from_input_validator, - ] - ) - - # Hex string path routes any string through the constructor. - # The constructor strips an optional 0x prefix, decodes hex, and length-checks. - str_path = core_schema.chain_schema( - [ - core_schema.str_schema(), - from_input_validator, - ] - ) - - # Final union accepts any branch and serializes back to a 0x-prefixed hex string: - # - # - Branch 1: input is already a typed instance, pass through. - # - Branch 2: input is bytes that need length-checking and wrapping. - # - Branch 3: input is a hex string that goes through the constructor. - return core_schema.union_schema( - [ - core_schema.is_instance_schema(cls), - bytes_path, - str_path, - ], - serialization=core_schema.plain_serializer_function_ser_schema( - lambda x: "0x" + x.hex() - ), - ) - - def __repr__(self) -> str: - """Return the official form: ClassName(hex_string).""" - type_name = type(self).__name__ - return f"{type_name}({self.hex()})" - - def __eq__(self, other: object) -> bool: - """Strict equality — only another byte-array instance compares; anything else raises.""" - if not isinstance(other, BaseBytes): - raise TypeError( - f"Unsupported operand type(s) for ==: " - f"'{type(self).__name__}' and '{type(other).__name__}'" - ) - return bytes.__eq__(self, other) - - def __ne__(self, other: object) -> bool: - """ - Strict inequality — only another byte-array instance compares; anything else raises. - - Defined explicitly because the parent bytes class has its own not-equal - that would otherwise bypass the strict type contract. - """ - if not isinstance(other, BaseBytes): - raise TypeError( - f"Unsupported operand type(s) for !=: " - f"'{type(self).__name__}' and '{type(other).__name__}'" - ) - return bytes.__ne__(self, other) - - def __hash__(self) -> int: - """Return a hash distinct from raw bytes — matches the strict equality contract.""" - return hash((type(self), bytes(self))) - - -class Bytes4(BaseBytes): - """Fixed-size byte array of exactly 4 bytes.""" - - LENGTH = 4 - - -class Bytes16(BaseBytes): - """Fixed-size byte array of exactly 16 bytes (Poly1305 authentication tag).""" - - LENGTH = 16 - - -class Bytes20(BaseBytes): - """Fixed-size byte array of exactly 20 bytes.""" - - LENGTH = 20 - - -class Bytes32(BaseBytes): - """Fixed-size byte array of exactly 32 bytes.""" - - LENGTH = 32 - - -class Bytes33(BaseBytes): - """Fixed-size byte array of exactly 33 bytes (compressed secp256k1 public key).""" - - LENGTH = 33 - - -class Bytes52(BaseBytes): - """Fixed-size byte array of exactly 52 bytes.""" - - LENGTH = 52 - - -class Bytes64(BaseBytes): - """Fixed-size byte array of exactly 64 bytes (secp256k1 signature).""" - - LENGTH = 64 - - -ZERO_HASH: Bytes32 = Bytes32.zero() -"""All-zero 32-byte hash, used as a canonical empty/uninitialized root.""" - - -class BaseByteList(SSZModel): - r""" - Variable-length SSZ byte array with 0 to N bytes. - - - Subclasses pin the maximum byte count by setting the class-level limit. - - Serialization writes the raw bytes; the length is recovered from the wrapping context. - - Equality is strict — only another byte-list instance compares. - - For example, a 4-byte payload under a limit of 10: - - instance.data = b"\xde\xad\xbe\xef" -> wire bytes de ad be ef - """ - - LIMIT: ClassVar[int] - """Maximum number of bytes the instance may contain.""" - - data: bytes = Field(default=b"") - """The raw bytes stored in this list.""" - - @field_validator("data", mode="before") - @classmethod - def _validate_byte_list_data(cls, value: Any) -> bytes: - """Enforce the maximum byte count and coerce inputs into a plain bytes object.""" - # Subclasses must declare LIMIT before any instances can be validated. - if not hasattr(cls, "LIMIT"): - raise SSZTypeError(f"{cls.__name__} must define LIMIT") - - # Coerce the input first, then enforce the upper bound. - coerced_bytes = BaseBytes._coerce_to_bytes(value) - if len(coerced_bytes) > cls.LIMIT: - raise SSZValueError( - f"{cls.__name__} exceeds limit of {cls.LIMIT}, got {len(coerced_bytes)}" - ) - return coerced_bytes - - @field_serializer("data", when_used="json") - def _serialize_data(self, value: bytes) -> str: - """Serialize the raw bytes to a 0x-prefixed hex string for JSON output.""" - return "0x" + value.hex() - - @classmethod - @override - def is_fixed_size(cls) -> bool: - """Variable-size by definition — the byte count depends on the value.""" - return False - - @classmethod - @override - def get_byte_length(cls) -> int: - """ - Variable-size types have no fixed byte length. - - Raises: - SSZTypeError: Always — call this only on fixed-size types. - """ - raise SSZTypeError(f"{cls.__name__}: variable-size byte list has no fixed byte length") - - @override - def serialize(self, stream: IO[bytes]) -> int: - """Write the raw bytes to a binary stream and return the number of bytes written.""" - stream.write(self.data) - return len(self.data) - - @classmethod - @override - def deserialize(cls, stream: IO[bytes], scope: int) -> Self: - """ - Read scope bytes from a stream into a new instance. - - For variable-size values, the caller computes scope from the surrounding context. - - Args: - stream: Source binary stream. - scope: Number of bytes belonging to this value. - - Returns: - A new instance wrapping the read bytes. - - Raises: - SSZSerializationError: - - When scope is negative. - - When the stream ends before delivering scope bytes. - SSZValueError: When scope exceeds the declared LIMIT. - """ - if scope < 0: - raise SSZSerializationError(f"{cls.__name__}: negative scope") - if scope > cls.LIMIT: - raise SSZValueError(f"{cls.__name__} exceeds limit of {cls.LIMIT}, got {scope}") - serialized_bytes = stream.read(scope) - if len(serialized_bytes) != scope: - raise SSZSerializationError( - f"{cls.__name__}: expected {scope} bytes, got {len(serialized_bytes)}" - ) - return cls(data=serialized_bytes) - - @override - def encode_bytes(self) -> bytes: - """Return the SSZ-encoded bytes — the raw payload, with no length prefix.""" - return self.data - - @classmethod - @override - def decode_bytes(cls, data: bytes) -> Self: - """Parse SSZ bytes into an instance — the validator enforces the LIMIT.""" - return cls(data=data) - - def __bytes__(self) -> bytes: - """Return the underlying raw bytes.""" - return self.data - - def __add__(self, other: Any) -> bytes: - """Concatenate with a bytes-like value on the right, returning plain bytes.""" - return self.data + bytes(other) - - def __radd__(self, other: Any) -> bytes: - """Concatenate with a bytes-like value on the left, returning plain bytes.""" - return bytes(other) + self.data - - def __repr__(self) -> str: - """Return the official form: ClassName(hex_string).""" - type_name = type(self).__name__ - return f"{type_name}({self.data.hex()})" - - def __eq__(self, other: object) -> bool: - """Strict equality — only another byte-list instance compares; anything else raises.""" - if not isinstance(other, BaseByteList): - raise TypeError( - f"Unsupported operand type(s) for ==: " - f"'{type(self).__name__}' and '{type(other).__name__}'" - ) - return self.data == other.data - - def __ne__(self, other: object) -> bool: - """ - Strict inequality — only another byte-list instance compares; anything else raises. - - Mirrors the strict equality contract — both operators require a matching type. - """ - if not isinstance(other, BaseByteList): - raise TypeError( - f"Unsupported operand type(s) for !=: " - f"'{type(self).__name__}' and '{type(other).__name__}'" - ) - return self.data != other.data - - def __hash__(self) -> int: - """Return a hash that ties the value to its concrete type.""" - return hash((type(self), self.data)) - - def hex(self) -> str: - """Return the hexadecimal string representation of the underlying bytes.""" - return self.data.hex() - - -class ByteList512KiB(BaseByteList): - """Variable-length byte list with a 512 KiB limit.""" - - LIMIT = 512 * 1024 diff --git a/src/lean_spec/spec/ssz/collections.py b/src/lean_spec/spec/ssz/collections.py deleted file mode 100644 index f7785545b..000000000 --- a/src/lean_spec/spec/ssz/collections.py +++ /dev/null @@ -1,631 +0,0 @@ -""" -SSZ vector and list collections. - -Two sequence shapes are defined by the SSZ spec: - -- A vector holds exactly LENGTH elements of one declared type. -- A list holds between zero and LIMIT elements of one declared type. - -A type is fixed-size when every value encodes to the same number of bytes. -A variable-size type allows different values to encode to different widths. - -The encoding shape follows from the element type: - -- Fixed-size elements share one known width. - Bodies pack back-to-back with no separator. - -- Variable-size elements are prefixed by a uint32 offset table. - Each offset is a byte position from the start of the sequence. - It points at the start of one encoded element body. - -The offset table takes 4 * N bytes for N elements. -The first offset therefore equals 4 * N — the byte position right after the table. - -For example, three variable-size bodies of widths 5, 3, and 7 encode to 27 bytes: - - bytes 0..3 : off_0 = 12 (first body starts at byte 12) - bytes 4..7 : off_1 = 17 (second body starts at byte 17) - bytes 8..11 : off_2 = 20 (third body starts at byte 20) - bytes 12..16 : body_0 (5 bytes) - bytes 17..19 : body_1 (3 bytes) - bytes 20..26 : body_2 (7 bytes) -""" - -import io -from collections.abc import Iterator, Sequence -from itertools import pairwise -from typing import ( - IO, - Any, - ClassVar, - Self, - cast, - overload, - override, -) - -from pydantic import Field, field_serializer, field_validator - -from lean_spec.spec.ssz.byte_arrays import BaseBytes -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError, SSZValueError -from lean_spec.spec.ssz.ssz_base import BYTES_PER_LENGTH_OFFSET, SSZModel, SSZType -from lean_spec.spec.ssz.uint import Uint32 - - -def _validate_offsets(offsets: list[int], scope: int, type_name: str) -> None: - """ - Enforce the offset-table invariants before reading element bodies. - - Two rules imply that every (start, end) pair is valid: - - - Offsets are monotonically non-decreasing, so no body has negative width. - - The final offset stays within scope, so no body reads past its budget. - - The container decoder repeats these same checks inline. - The duplication is intentional: inlining there keeps the per-field name in each error. - - Raises: - SSZSerializationError: When a later offset is smaller than an earlier one. - SSZSerializationError: When the final offset exceeds the available scope. - """ - # Empty sequences have no bodies and therefore no boundaries to enforce. - if not offsets: - return - - # Pairwise comparison catches any decreasing step in the table. - for previous_offset, current_offset in pairwise(offsets): - if current_offset < previous_offset: - raise SSZSerializationError( - f"{type_name}: offsets not monotonically increasing: " - f"{previous_offset} -> {current_offset}" - ) - - # The final boundary is the scope appended by the decoder. - # A larger final offset would extend past the available bytes. - if offsets[-1] > scope: - raise SSZSerializationError( - f"{type_name}: final offset {offsets[-1]} exceeds scope {scope}" - ) - - -def _coerce_elements(element_type: type[SSZType], elements: Sequence[Any]) -> tuple[SSZType, ...]: - """ - Coerce every element of an already-shaped sequence into the declared type. - - - Already-typed elements pass through untouched. - - Every other element goes through the element type's constructor. - - A coercion failure re-raises with the high-level expectation in the message. - - The chained cause preserves the underlying coercion detail. - """ - coerced: list[SSZType] = [] - for element in elements: - if isinstance(element, element_type): - coerced.append(element) - continue - try: - coerced.append(cast(Any, element_type)(element)) - except (SSZTypeError, SSZValueError, TypeError, ValueError) as exception: - raise SSZTypeError( - f"Expected {element_type.__name__}, got {type(element).__name__}: {exception}" - ) from exception - return tuple(coerced) - - -class _SSZSequence[T: SSZType](SSZModel): - """ - Shared scaffolding for fixed- and variable-length SSZ sequences. - - Two subclasses concretize this base: - - - A vector pins the element count at LENGTH. - - A list bounds the element count by LIMIT. - - Both store elements in a Pydantic field named data. - Both expose tuple-style iteration and indexing. - Both share the offset-table writer used by variable-size encodings. - - The element type is inferred from the generic parameter, once per subclass. - """ - - ELEMENT_TYPE: ClassVar[type[SSZType]] - """SSZ type of every element, inferred from the generic parameter.""" - - data: Sequence[T] = Field(default_factory=tuple) - """ - Immutable sequence of elements. - - Accepts lists, tuples, or iterables of compatible values on input. - Stored as an immutable tuple after validation. - """ - - def __init_subclass__(cls, **kwargs: Any) -> None: - """ - Read the element type from the generic parameter in a class declaration. - - When a subclass is written as: - - class Uint16Vector2(SSZVector[Uint16]): - LENGTH = 2 - - the Uint16 inside the brackets is copied into Uint16Vector2.ELEMENT_TYPE. - This way, a user does not have to write ELEMENT_TYPE = Uint16 by hand. - """ - super().__init_subclass__(**kwargs) - if "ELEMENT_TYPE" in cls.__dict__: - return - - # Walk direct parents looking for a parameterized SSZ sequence base. - # The first concrete element type wins. - # Layers carrying only a TypeVar are skipped. - for base in cls.__bases__: - # Pydantic stores the generic parameterization on every generic parent. - # An empty default skips bases that were never made generic. - metadata = getattr(base, "__pydantic_generic_metadata__", {}) - - # Origin is the unparameterized class — for example SSZVector itself. - # Skip bases outside the sequence hierarchy. - origin = metadata.get("origin") - if not (isinstance(origin, type) and issubclass(origin, _SSZSequence)): - continue - - # Args holds the types that appeared between the brackets. - # A real SSZType subclass wins. - # A bare TypeVar means an abstract layer has not bound the parameter yet. - for arg in metadata.get("args", ()): - if isinstance(arg, type) and issubclass(arg, SSZType): - cls.ELEMENT_TYPE = arg - return - - @field_serializer("data", when_used="json") - def _serialize_data(self, value: Sequence[T]) -> list[Any]: - """ - Render the elements as a JSON-friendly list. - - Two leaf shapes need bespoke handling: - - - Byte arrays render as 0x-prefixed hex strings. - - Integer leaves (uints, field elements) flatten to a plain int. - - Anything else passes through for Pydantic's downstream serializers. - """ - # Pydantic does not auto-flatten SSZ leaf types into JSON primitives. - # Each element is inspected and rewritten according to the rules below. - serialized_elements: list[Any] = [] - for element in value: - # Byte-array leaves render as 0x-prefixed hex strings. - # This matches how every other byte value appears in spec output. - if isinstance(element, BaseBytes): - serialized_elements.append("0x" + element.hex()) - - # Integer leaves (uints, field elements) flatten to a plain int. - # Bool also subclasses int. - # It is excluded so True and False survive in JSON unchanged. - elif isinstance(element, int) and not isinstance(element, bool): - serialized_elements.append(int(element)) - - # Anything else passes through for Pydantic's downstream serializers. - # Nested containers, booleans, strings, and primitive values land here. - else: - serialized_elements.append(element) - return serialized_elements - - def _write_variable_payload(self, stream: IO[bytes], offset_count: int) -> int: - """ - Write the offset table followed by the buffered element bodies. - - Offsets are emitted to the output stream first. - Bodies are buffered and flushed after the table. - - Args: - stream: Output binary stream. - offset_count: Number of offset entries in the table. - - Returns: - Total bytes written, equal to the final offset value. - """ - # A forward-only stream cannot revisit earlier offset slots to fix them up. - # Bodies must be buffered until the table is fully written. - bodies = io.BytesIO() - - # The first offset points past the entire offset table. - # Each subsequent offset advances by the previous body's width. - offset = offset_count * BYTES_PER_LENGTH_OFFSET - for element in self.data: - Uint32(offset).serialize(stream) - offset += element.serialize(bodies) - - # Bodies land at the byte positions the offsets just declared. - stream.write(bodies.getvalue()) - return offset - - @override - def __len__(self) -> int: - """Return the number of elements in the sequence.""" - return len(self.data) - - # The parent Pydantic model iterates field name and value pairs. - # Yielding elements instead is the intended collection behavior. - # The narrower element type violates strict Liskov substitution, so it is suppressed. - @override - def __iter__(self) -> Iterator[T]: # ty: ignore[invalid-method-override] - """ - Iterate over the elements. - - Defined explicitly because the parent Pydantic model otherwise yields - name/value pairs of its fields. - """ - return iter(self.data) - - @overload - def __getitem__(self, index: int) -> T: ... - @overload - def __getitem__(self, index: slice) -> Sequence[T]: ... - - def __getitem__(self, index: int | slice) -> T | Sequence[T]: - """Index by integer or slice the underlying tuple.""" - return self.data[index] - - @property - def elements(self) -> list[T]: - """Return a mutable copy of the elements as a list.""" - return list(self.data) - - @classmethod - def _shape_input(cls, raw_input: Any) -> Sequence[Any]: - """ - Normalize a validator input into a length-checkable sequence. - - Accept the natural input shapes: - - - list or tuple pass through directly. - - other iterables materialize into a list so the length check works. - - str or bytes rejected — iterating yields characters or ints. - - The subclass enforces its own element-count rule on the returned sequence. - - Raises: - SSZTypeError: When the input is a string, bytes, or non-iterable. - """ - if isinstance(raw_input, (list, tuple)): - return raw_input - if isinstance(raw_input, (str, bytes, bytearray)): - raise SSZTypeError( - f"{cls.__name__}: Expected iterable of {cls.ELEMENT_TYPE.__name__}, " - f"got {type(raw_input).__name__}" - ) - if hasattr(raw_input, "__iter__"): - return list(raw_input) - raise SSZTypeError(f"{cls.__name__}: Expected iterable, got {type(raw_input).__name__}") - - -class SSZVector[T: SSZType](_SSZSequence[T]): - """ - Fixed-length, immutable SSZ sequence. - - Holds exactly LENGTH elements of one declared type. - The element count is pinned at the type level and never changes at runtime. - - Two encoding shapes follow from the element type: - - - Fixed-size elements pack back-to-back with no separators. - - Variable-size elements use the offset-table layout. - - Subclasses declare LENGTH directly in the class body. - The element type is inferred from the generic parameter. - - For example, three Uint16 values encode as six raw bytes: - - bytes 0..1 : 67 45 (= 0x4567, little-endian) - bytes 2..3 : 23 01 (= 0x0123) - bytes 4..5 : ef cd (= 0xCDEF) - - Two variable-size bodies of widths 5 and 7 encode to 20 bytes: - - bytes 0..3 : off_0 = 8 (first body starts at byte 8) - bytes 4..7 : off_1 = 13 (second body starts at byte 13) - bytes 8..12 : body_0 (5 bytes) - bytes 13..19 : body_1 (7 bytes) - """ - - LENGTH: ClassVar[int] - """Exact number of elements, fixed at the type level.""" - - @field_validator("data", mode="before") - @classmethod - def _coerce_and_validate(cls, raw_input: Any) -> tuple[SSZType, ...]: - """ - Enforce the exact element count and coerce inputs into ELEMENT_TYPE. - - Three rejections happen before coercion: - - - Misconfigured subclasses without ELEMENT_TYPE or LENGTH fail. - - String and bytes inputs are rejected to avoid silent character iteration. - - Non-iterable inputs fail fast with a descriptive message. - - Each element passes through the declared type's constructor on coercion. - Failures re-raise with the high-level expectation in the message. - The chained cause preserves the underlying coercion detail. - """ - # Subclasses must declare both annotations before any instance can validate. - if not hasattr(cls, "ELEMENT_TYPE") or not hasattr(cls, "LENGTH"): - raise SSZTypeError(f"{cls.__name__} must define ELEMENT_TYPE and LENGTH") - - # Reject strings and non-iterables, then materialize into a sequence. - input_elements = cls._shape_input(raw_input) - - # Fixed-length type: the input must contain exactly LENGTH elements. - if len(input_elements) != cls.LENGTH: - raise SSZValueError( - f"{cls.__name__} requires exactly {cls.LENGTH} elements, got {len(input_elements)}" - ) - - return _coerce_elements(cls.ELEMENT_TYPE, input_elements) - - @classmethod - @override - def is_fixed_size(cls) -> bool: - """A vector is fixed-size if and only if its elements are fixed-size.""" - return cls.ELEMENT_TYPE.is_fixed_size() - - @classmethod - @override - def get_byte_length(cls) -> int: - """ - Return the fixed encoded byte length. - - Raises: - SSZTypeError: When the element type is variable-size. - """ - if not cls.is_fixed_size(): - raise SSZTypeError(f"{cls.__name__}: variable-size vector has no fixed byte length") - return cls.ELEMENT_TYPE.get_byte_length() * cls.LENGTH - - @override - def serialize(self, stream: IO[bytes]) -> int: - """Write the SSZ encoding to a binary stream and return the byte count.""" - # Fixed-size elements: serialize each body directly, no offsets needed. - if self.is_fixed_size(): - return sum(element.serialize(stream) for element in self.data) - # Variable-size elements: emit a table of LENGTH offsets, then the bodies. - return self._write_variable_payload(stream, self.LENGTH) - - @classmethod - @override - def deserialize(cls, stream: IO[bytes], scope: int) -> Self: - """ - Read one vector from a binary stream within the given byte budget. - - Two cases mirror the encoder: - - - Fixed-size elements: scope equals LENGTH times the element byte width. - - Variable-size elements: a LENGTH-wide offset table precedes the bodies. - - Raises: - SSZSerializationError: When scope or any offset is inconsistent. - """ - # Fixed-size case: elements pack back-to-back at a known stride. - # The byte budget must match LENGTH times the element width exactly. - if cls.is_fixed_size(): - element_byte_length = cls.ELEMENT_TYPE.get_byte_length() - expected_total = element_byte_length * cls.LENGTH - if scope != expected_total: - raise SSZSerializationError( - f"{cls.__name__}: expected {expected_total} bytes, got {scope}" - ) - elements = [ - cls.ELEMENT_TYPE.deserialize(stream, element_byte_length) for _ in range(cls.LENGTH) - ] - return cls(data=elements) - - # Variable-size case: read the full offset table, then slice each body. - # - # Scope must cover at least the offset table itself. - # The first offset must then equal the table's own byte width. - # Scope is appended as the final boundary so pairwise iteration yields every span. - expected_first = cls.LENGTH * BYTES_PER_LENGTH_OFFSET - if scope < expected_first: - raise SSZSerializationError( - f"{cls.__name__}: scope {scope} too small, expected at least {expected_first}" - ) - offsets = [ - int(Uint32.deserialize(stream, BYTES_PER_LENGTH_OFFSET)) for _ in range(cls.LENGTH) - ] - if offsets[0] != expected_first: - raise SSZSerializationError( - f"{cls.__name__}: invalid offset {offsets[0]}, expected {expected_first}" - ) - offsets.append(scope) - _validate_offsets(offsets, scope, cls.__name__) - - return cls( - data=[ - cls.ELEMENT_TYPE.deserialize(stream, end - start) - for start, end in pairwise(offsets) - ] - ) - - -class SSZList[T: SSZType](_SSZSequence[T]): - """ - Variable-length SSZ sequence with a maximum capacity. - - Holds between zero and LIMIT elements of one declared type. - The element count is set at construction time and varies between instances. - - Two encoding shapes mirror the vector cases: - - - Fixed-size elements pack back-to-back, count recovered from wire scope. - - Variable-size elements use an offset table that also reveals the count. - - The hash tree root mixes in the element count alongside the chunked data. - Two lists with identical contents but different LIMITs hash differently. - - Subclasses declare LIMIT directly in the class body. - The element type is inferred from the generic parameter. - - For example, three Uint16 values under a limit of eight encode as six bytes: - - bytes 0..1 : bb aa (= 0xAABB, little-endian, no length prefix) - bytes 2..3 : ad c0 (= 0xC0AD) - bytes 4..5 : ff ee (= 0xEEFF) - - Two variable-size bodies of widths 4 and 6 encode to 18 bytes: - - bytes 0..3 : off_0 = 8 (first body starts at byte 8) - bytes 4..7 : off_1 = 12 (second body starts at byte 12) - bytes 8..11 : body_0 (4 bytes) - bytes 12..17 : body_1 (6 bytes) - """ - - LIMIT: ClassVar[int] - """Maximum number of elements allowed.""" - - @field_validator("data", mode="before") - @classmethod - def _coerce_and_validate(cls, raw_input: Any) -> tuple[SSZType, ...]: - """ - Enforce the maximum element count and coerce inputs into ELEMENT_TYPE. - - Three rejections happen before coercion: - - - Misconfigured subclasses without ELEMENT_TYPE or LIMIT fail. - - String and bytes inputs are rejected to avoid silent character iteration. - - Non-iterable inputs fail fast with a descriptive message. - - Each element passes through the declared type's constructor on coercion. - Failures re-raise with the high-level expectation in the message. - The chained cause preserves the underlying coercion detail. - """ - # Subclasses must declare both annotations before any instance can validate. - if not hasattr(cls, "ELEMENT_TYPE") or not hasattr(cls, "LIMIT"): - raise SSZTypeError(f"{cls.__name__} must define ELEMENT_TYPE and LIMIT") - - # Reject strings and non-iterables, then materialize into a sequence. - input_elements = cls._shape_input(raw_input) - - # Variable-length type: any count is fine, up to LIMIT. - if len(input_elements) > cls.LIMIT: - raise SSZValueError( - f"{cls.__name__} exceeds limit of {cls.LIMIT}, got {len(input_elements)}" - ) - - return _coerce_elements(cls.ELEMENT_TYPE, input_elements) - - def __add__(self, other: Any) -> Self: - """ - Concatenate with another sequence and return a new list. - - The validator on the resulting instance enforces LIMIT. - Overflowing concatenations raise SSZValueError at construction. - """ - match other: - case SSZList(): - new_data = (*self.data, *other.data) - case list() | tuple(): - new_data = (*self.data, *other) - case _: - return NotImplemented - return type(self)(data=new_data) - - @classmethod - @override - def is_fixed_size(cls) -> bool: - """A list is never fixed-size since the element count ranges from zero to LIMIT.""" - return False - - @classmethod - @override - def get_byte_length(cls) -> int: - """ - Variable-size types have no fixed byte length. - - Raises: - SSZTypeError: Always — call this only on fixed-size types. - """ - raise SSZTypeError(f"{cls.__name__}: variable-size list has no fixed byte length") - - @override - def serialize(self, stream: IO[bytes]) -> int: - """Write the SSZ encoding to a binary stream and return the byte count.""" - # Fixed-size elements pack back-to-back with no length prefix. - # The element count is recovered on decode from the wire scope. - if self.ELEMENT_TYPE.is_fixed_size(): - return sum(element.serialize(stream) for element in self.data) - # Variable-size elements: emit a table sized for the runtime count, then bodies. - return self._write_variable_payload(stream, len(self.data)) - - @classmethod - @override - def deserialize(cls, stream: IO[bytes], scope: int) -> Self: - """ - Read one list from a binary stream within the given byte budget. - - Three cases cover all valid inputs: - - - Empty scope decodes to an empty list. - - Fixed-size elements: count equals scope divided by element width. - - Variable-size elements: the first offset locates bodies and reveals the count. - - Raises: - SSZSerializationError: When scope or any offset is malformed. - SSZValueError: When the recovered element count exceeds LIMIT. - """ - # Empty case: any zero-byte payload decodes to an empty list. - if scope == 0: - return cls(data=()) - - # Fixed-size case: elements pack back-to-back at a known stride. - # The count is recovered by dividing the byte budget by the element width. - if cls.ELEMENT_TYPE.is_fixed_size(): - element_size = cls.ELEMENT_TYPE.get_byte_length() - if scope % element_size != 0: - raise SSZSerializationError( - f"{cls.__name__}: scope {scope} not divisible by element size {element_size}" - ) - num_elements = scope // element_size - if num_elements > cls.LIMIT: - raise SSZValueError( - f"{cls.__name__} exceeds limit of {cls.LIMIT}, got {num_elements}" - ) - elements = [ - cls.ELEMENT_TYPE.deserialize(stream, element_size) for _ in range(num_elements) - ] - return cls(data=elements) - - # Variable-size case: the first offset reveals both where bodies begin - # and the element count (the offset width divides the table width). - if scope < BYTES_PER_LENGTH_OFFSET: - raise SSZSerializationError( - f"{cls.__name__}: scope {scope} too small for variable-size list" - ) - first_offset = int(Uint32.deserialize(stream, BYTES_PER_LENGTH_OFFSET)) - # A non-empty variable-size list carries at least one offset word before any body. - # A zero first offset is contradictory: it means zero elements yet one full-scope element. - if ( - first_offset < BYTES_PER_LENGTH_OFFSET - or first_offset > scope - or first_offset % BYTES_PER_LENGTH_OFFSET != 0 - ): - raise SSZSerializationError(f"{cls.__name__}: invalid offset {first_offset}") - num_elements = first_offset // BYTES_PER_LENGTH_OFFSET - if num_elements > cls.LIMIT: - raise SSZValueError(f"{cls.__name__} exceeds limit of {cls.LIMIT}, got {num_elements}") - - # Read the remaining offsets, append scope as the final boundary, - # then pairwise-iterate the boundary list to yield each body's byte span. - offsets = [ - first_offset, - *( - int(Uint32.deserialize(stream, BYTES_PER_LENGTH_OFFSET)) - for _ in range(num_elements - 1) - ), - scope, - ] - _validate_offsets(offsets, scope, cls.__name__) - - return cls( - data=[ - cls.ELEMENT_TYPE.deserialize(stream, end - start) - for start, end in pairwise(offsets) - ] - ) diff --git a/src/lean_spec/spec/ssz/container.py b/src/lean_spec/spec/ssz/container.py deleted file mode 100644 index 6b4c83244..000000000 --- a/src/lean_spec/spec/ssz/container.py +++ /dev/null @@ -1,121 +0,0 @@ -"""SSZ Container Type.""" - -import io -from itertools import pairwise -from typing import IO, Any, Self, override - -from pydantic import model_validator -from pydantic.functional_validators import ModelWrapValidatorHandler - -from lean_spec.spec.ssz.exceptions import SSZError, SSZSerializationError, SSZTypeError -from lean_spec.spec.ssz.ssz_base import BYTES_PER_LENGTH_OFFSET, SSZModel, SSZType -from lean_spec.spec.ssz.uint import Uint32 - - -class Container(SSZModel): - """Ordered struct of named heterogeneous SSZ fields.""" - - @model_validator(mode="wrap") - @classmethod - def _accept_hex_string(cls, value: Any, handler: ModelWrapValidatorHandler[Self]) -> Self: - """ - Reconstruct the container from a hex-encoded SSZ payload. - - - Other input shapes pass through to field-by-field validation. - - Hex strings accept an optional 0x prefix. - """ - if isinstance(value, str): - try: - return cls.from_hex(value) - except SSZError as exception: - raise ValueError(f"invalid {cls.__name__} hex: {exception}") from exception - return handler(value) - - @classmethod - @override - def is_fixed_size(cls) -> bool: - """True only when every field is fixed-size.""" - return all(f.annotation.is_fixed_size() for f in cls.model_fields.values()) - - @classmethod - @override - def get_byte_length(cls) -> int: - """Sum of field widths; raises for variable-size containers.""" - if not cls.is_fixed_size(): - raise SSZTypeError(f"{cls.__name__}: variable-size container has no fixed byte length") - return sum(f.annotation.get_byte_length() for f in cls.model_fields.values()) - - @override - def serialize(self, stream: IO[bytes]) -> int: - """Write the fixed part with offsets, then the variable payloads.""" - field_values = [getattr(self, name) for name in type(self).model_fields] - - # Leading-part width: each slot is either the field's byte length or one offset. - offset = sum( - type(v).get_byte_length() if type(v).is_fixed_size() else BYTES_PER_LENGTH_OFFSET - for v in field_values - ) - - # Variable payloads stage in a buffer while the output takes the fixed part. - tail = io.BytesIO() - for field_value in field_values: - if type(field_value).is_fixed_size(): - field_value.serialize(stream) - else: - Uint32(offset).serialize(stream) - offset += field_value.serialize(tail) - stream.write(tail.getvalue()) - return offset - - @classmethod - @override - def deserialize(cls, stream: IO[bytes], scope: int) -> Self: - """Read the fixed part with offsets, then each variable payload by its offset window.""" - fields: dict[str, SSZType] = {} - variable_fields: list[tuple[str, type[SSZType], int]] = [] - bytes_read = 0 - - # Phase 1: each slot is either the field itself or an offset to its tail payload. - for name, field_definition in cls.model_fields.items(): - field_type: type[SSZType] = field_definition.annotation - if field_type.is_fixed_size(): - width = field_type.get_byte_length() - fields[name] = field_type.deserialize(stream, width) - bytes_read += width - else: - offset = int(Uint32.deserialize(stream, BYTES_PER_LENGTH_OFFSET)) - variable_fields.append((name, field_type, offset)) - bytes_read += BYTES_PER_LENGTH_OFFSET - - if not variable_fields: - return cls(**fields) - - # These offset checks mirror the variable-length collection decoder. - # The duplication is intentional: inlining keeps the per-field name in each error. - # - # Canonical form: the first offset must point to the end of the fixed part. - # Any other value leaves a gap or overlap, allowing two encodings of one value. - if variable_fields[0][2] != bytes_read: - first_offset = variable_fields[0][2] - raise SSZSerializationError( - f"{cls.__name__}: first offset {first_offset} != fixed-part end {bytes_read}" - ) - - # Phase 2: each variable payload spans from its offset to the next. - # Scope closes the final span. - boundaries = [offset for _, _, offset in variable_fields] + [scope] - for (name, field_type, _), (start, end) in zip( - variable_fields, pairwise(boundaries), strict=True - ): - if end < start: - raise SSZSerializationError( - f"{cls.__name__}.{name}: non-monotonic offsets ({start} > {end})" - ) - fields[name] = field_type.deserialize(stream, end - start) - - return cls(**fields) - - @classmethod - def from_hex(cls, value: str) -> Self: - """Decode from a hex string with an optional 0x prefix.""" - return cls.decode_bytes(bytes.fromhex(value.removeprefix("0x"))) diff --git a/src/lean_spec/spec/ssz/exceptions.py b/src/lean_spec/spec/ssz/exceptions.py deleted file mode 100644 index c768820cc..000000000 --- a/src/lean_spec/spec/ssz/exceptions.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Exception hierarchy for the SSZ type system.""" - -from __future__ import annotations - - -class SSZError(Exception): - """Base exception for all SSZ-related errors.""" - - -class SSZTypeError(SSZError): - """Raised for type-related errors (coercion, definition, invalid types).""" - - -class SSZValueError(SSZError): - """Raised for value-related errors (overflow, length, bounds).""" - - -class SSZSerializationError(SSZError): - """Raised for serialization errors (encoding, decoding, stream issues).""" diff --git a/src/lean_spec/spec/ssz/ssz_base.py b/src/lean_spec/spec/ssz/ssz_base.py deleted file mode 100644 index f9dcec72b..000000000 --- a/src/lean_spec/spec/ssz/ssz_base.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Abstract bases for the SSZ type system.""" - -import io -from abc import ABC, abstractmethod -from typing import IO, Final, Self - -from lean_spec.base import StrictBaseModel -from lean_spec.spec.ssz.exceptions import SSZSerializationError - -BYTES_PER_LENGTH_OFFSET: Final = 4 -"""Width of an SSZ offset prefixing each variable-size element. - -Encoded as a uint32 in little-endian byte order.""" - - -class SSZType(ABC): - """Abstract base for every SSZ-encodable type.""" - - @classmethod - @abstractmethod - def is_fixed_size(cls) -> bool: - """ - Whether every instance encodes to the same number of bytes. - - Returns: - True for fixed-size types, False for variable-size. - """ - ... - - @classmethod - @abstractmethod - def get_byte_length(cls) -> int: - """ - Fixed encoded byte length of this type. - - Returns: - The constant byte width every instance encodes to. - - Raises: - SSZTypeError: If the type is variable-size. - """ - ... - - @abstractmethod - def serialize(self, stream: IO[bytes]) -> int: - """ - Write the SSZ encoding to a binary stream. - - Args: - stream: Output binary stream. - - Returns: - Number of bytes written. - """ - ... - - @classmethod - @abstractmethod - def deserialize(cls, stream: IO[bytes], scope: int) -> Self: - """ - Read one value from a binary stream within a bounded byte budget. - - Args: - stream: Source binary stream. - scope: Number of bytes belonging to this value. - - Returns: - A new instance reconstructed from the stream. - """ - ... - - def encode_bytes(self) -> bytes: - """ - Encode this value to its SSZ byte representation. - - Returns: - Serialized bytes. - """ - stream = io.BytesIO() - self.serialize(stream) - return stream.getvalue() - - @classmethod - def decode_bytes(cls, data: bytes) -> Self: - """ - Decode SSZ bytes into a new instance. - - Rejects trailing bytes left over after the stream-based decoder finishes. - A spec decoder must accept exactly one canonical encoding per value. - - Args: - data: SSZ-encoded bytes containing exactly one value. - - Returns: - A new instance reconstructed from the input. - - Raises: - SSZSerializationError: If the input carries bytes past the decoded value. - """ - stream = io.BytesIO(data) - instance = cls.deserialize(stream, len(data)) - - # Spec contract: each canonical encoding maps to exactly one value. - # - # Any unread bytes mean the input either over-allocated or carries noise. - leftover = len(data) - stream.tell() - if leftover: - raise SSZSerializationError(f"{cls.__name__}: {leftover} trailing byte(s) after decode") - return instance - - -class SSZModel(StrictBaseModel, SSZType): - """ - Pydantic-backed SSZ base used by containers, lists, vectors, and bitfields. - - Two shapes share this base: - - - Collections wrap an inner sequence in one Pydantic field called data. - - Containers expose multiple named Pydantic fields that map to a struct on the wire. - - The default length and string forms switch on which shape the subclass uses. - """ - - def __len__(self) -> int: - """Element count for collections, field count for containers.""" - data_field = getattr(self, "data", None) - if data_field is not None: - return len(data_field) - return len(type(self).model_fields) - - def __repr__(self) -> str: - """Show collection contents as data=[...] or container fields as name=value pairs.""" - cls_name = type(self).__name__ - data_field = getattr(self, "data", None) - if data_field is not None: - return f"{cls_name}(data={list(data_field)!r})" - field_strs = [f"{name}={getattr(self, name)!r}" for name in type(self).model_fields] - return f"{cls_name}({' '.join(field_strs)})" diff --git a/src/lean_spec/spec/ssz/uint.py b/src/lean_spec/spec/ssz/uint.py deleted file mode 100644 index bc6eda693..000000000 --- a/src/lean_spec/spec/ssz/uint.py +++ /dev/null @@ -1,374 +0,0 @@ -"""Unsigned Integer Type Specification.""" - -from __future__ import annotations - -from typing import IO, Any, ClassVar, NoReturn, Self, SupportsInt, overload, override - -from pydantic.annotated_handlers import GetCoreSchemaHandler -from pydantic_core import core_schema - -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError, SSZValueError -from lean_spec.spec.ssz.ssz_base import SSZType - - -class BaseUint(int, SSZType): - """Base class for fixed-width unsigned integer types.""" - - __slots__ = () - - BITS: ClassVar[int] - """The number of bits in the integer (overridden by subclasses).""" - - def __new__(cls, value: SupportsInt) -> Self: - """ - Create and range-check a new instance. - - Raises: - SSZTypeError: If value is not an int. Bool, string, and float are rejected. - SSZValueError: If value is outside [0, 2**BITS - 1]. - """ - # Bool subclasses int, so reject it explicitly before the value check. - if not isinstance(value, int) or isinstance(value, bool): - raise SSZTypeError(f"Expected int, got {type(value).__name__}") - - int_value = int(value) - max_value = 2**cls.BITS - 1 - if not (0 <= int_value <= max_value): - raise SSZValueError(f"{int_value} out of range for {cls.__name__} [0, {max_value}]") - return super().__new__(cls, int_value) - - @classmethod - def __get_pydantic_core_schema__( - cls, source_type: Any, handler: GetCoreSchemaHandler - ) -> core_schema.CoreSchema: - """Hook into Pydantic's validation system.""" - # A plain validator wraps a pre-validated int into a typed instance. - from_int_validator = core_schema.no_info_plain_validator_function(cls) - # Strict int validation enforces the unsigned range before construction. - # - # The lt bound is exclusive, so a value equal to 2**BITS is rejected. - python_schema = core_schema.chain_schema( - [core_schema.int_schema(ge=0, lt=2**cls.BITS, strict=True), from_int_validator] - ) - # Existing instances bypass validation. - # - # Raw values flow through the strict chain instead. - return core_schema.union_schema( - [ - # Case 1: The value is already the correct type. - core_schema.is_instance_schema(cls), - # Case 2: The value needs to be parsed and validated. - python_schema, - ], - # Round-trip to JSON drops the subtype back to a plain int. - serialization=core_schema.plain_serializer_function_ser_schema(int), - ) - - @classmethod - @override - def is_fixed_size(cls) -> bool: - """All unsigned integer types are fixed-size.""" - return True - - @classmethod - @override - def get_byte_length(cls) -> int: - """Byte length derived from the bit width.""" - return cls.BITS // 8 - - @override - def encode_bytes(self) -> bytes: - """Serialize to little-endian bytes.""" - return self.to_bytes(length=self.get_byte_length(), byteorder="little") - - @classmethod - @override - def decode_bytes(cls, data: bytes) -> Self: - """ - Deserialize from little-endian bytes. - - Raises: - SSZSerializationError: If the byte string has the wrong length. - """ - # Ensure the input data has the correct number of bytes. - expected_length = cls.get_byte_length() - if len(data) != expected_length: - raise SSZSerializationError( - f"{cls.__name__}: expected {expected_length} bytes, got {len(data)}" - ) - return cls(int.from_bytes(data, "little")) - - @override - def serialize(self, stream: IO[bytes]) -> int: - """Write little-endian bytes to a stream and return the count written.""" - encoded_data = self.encode_bytes() - # Write the data to the stream. - stream.write(encoded_data) - # Return the number of bytes written. - return len(encoded_data) - - @classmethod - @override - def deserialize(cls, stream: IO[bytes], scope: int) -> Self: - """ - Read little-endian bytes from a stream within a fixed scope. - - Raises: - SSZSerializationError: If the scope mismatches, or the stream ends early. - """ - byte_length = cls.get_byte_length() - if scope != byte_length: - raise SSZSerializationError( - f"{cls.__name__}: invalid scope, expected {byte_length} bytes, got {scope}" - ) - # Read the required number of bytes from the stream. - serialized_bytes = stream.read(byte_length) - # Ensure the correct number of bytes was read. - if len(serialized_bytes) != byte_length: - raise SSZSerializationError( - f"{cls.__name__}: expected {byte_length} bytes, got {len(serialized_bytes)}" - ) - # Decode the bytes into a new instance. - return cls.decode_bytes(serialized_bytes) - - @classmethod - def max_value(cls) -> Self: - """The maximum value for this unsigned integer.""" - return cls(2**cls.BITS - 1) - - def _raise_type_error(self, other: Any, op_symbol: str) -> NoReturn: - """Helper to raise a consistent TypeError.""" - raise TypeError( - f"Unsupported operand type(s) for {op_symbol}: " - f"'{type(self).__name__}' and '{type(other).__name__}'" - ) - - def __add__(self, other: Any) -> Self: - """Forward addition.""" - if type(other) is not type(self): - self._raise_type_error(other, "+") - return type(self)(super().__add__(other)) - - def __radd__(self, other: Any) -> Self: - """Reverse addition.""" - if type(other) is not type(self): - self._raise_type_error(other, "+") - return type(self)(int(other) + int(self)) - - def __sub__(self, other: Any) -> Self: - """Forward subtraction.""" - if type(other) is not type(self): - self._raise_type_error(other, "-") - return type(self)(super().__sub__(other)) - - def __rsub__(self, other: Any) -> Self: - """Reverse subtraction.""" - if type(other) is not type(self): - self._raise_type_error(other, "-") - return type(self)(int(other) - int(self)) - - def __mul__(self, other: Any) -> Self: - """Forward multiplication.""" - if type(other) is not type(self): - self._raise_type_error(other, "*") - return type(self)(super().__mul__(other)) - - def __rmul__(self, other: Any) -> Self: - """Reverse multiplication.""" - if type(other) is not type(self): - self._raise_type_error(other, "*") - return type(self)(int(other) * int(self)) - - def __floordiv__(self, other: Any) -> Self: - """Forward floor division.""" - if type(other) is not type(self): - self._raise_type_error(other, "//") - return type(self)(super().__floordiv__(other)) - - def __rfloordiv__(self, other: Any) -> Self: - """Reverse floor division.""" - if type(other) is not type(self): - self._raise_type_error(other, "//") - return type(self)(int(other) // int(self)) - - def __mod__(self, other: Any) -> Self: - """Forward modulo.""" - if type(other) is not type(self): - self._raise_type_error(other, "%") - return type(self)(super().__mod__(other)) - - def __rmod__(self, other: Any) -> Self: - """Reverse modulo.""" - if type(other) is not type(self): - self._raise_type_error(other, "%") - return type(self)(int(other) % int(self)) - - @overload - def __pow__(self, value: int, mod: None = None, /) -> Self: ... - @overload - def __pow__(self, value: int, mod: int, /) -> Self: ... - # The parent declaration uses two stub overloads with different return types. - # - # Narrowing both to a single subtype is safe by Liskov substitution. - # The strict overload-match check rejects it regardless. - def __pow__(self, value: int, mod: int | None = None, /) -> Self: # ty: ignore[invalid-method-override] - """Forward exponentiation and three-argument pow.""" - if type(value) is not type(self): - self._raise_type_error(value, "**") - if mod is not None and type(mod) is not type(self): - self._raise_type_error(mod, "**") - power = pow(int(self), int(value), int(mod) if mod is not None else None) - return type(self)(power) - - def __rpow__(self, base: int, modulo: int | None = None, /) -> Self: - """Reverse exponentiation and three-argument pow.""" - if type(base) is not type(self): - self._raise_type_error(base, "**") - if modulo is not None and type(modulo) is not type(self): - self._raise_type_error(modulo, "**") - power = pow(int(base), int(self), int(modulo) if modulo is not None else None) - return type(self)(power) - - def __divmod__(self, other: Any) -> tuple[Self, Self]: - """Forward divmod.""" - if type(other) is not type(self): - self._raise_type_error(other, "divmod") - quotient, remainder = super().__divmod__(other) - return type(self)(quotient), type(self)(remainder) - - def __rdivmod__(self, other: Any) -> tuple[Self, Self]: - """Reverse divmod.""" - if type(other) is not type(self): - self._raise_type_error(other, "divmod") - quotient, remainder = super().__rdivmod__(other) - return type(self)(quotient), type(self)(remainder) - - def __and__(self, other: Any) -> Self: - """Forward bitwise AND.""" - if type(other) is not type(self): - self._raise_type_error(other, "&") - return type(self)(super().__and__(other)) - - def __rand__(self, other: Any) -> Self: - """Reverse bitwise AND.""" - return self.__and__(other) - - def __or__(self, other: Any) -> Self: - """Forward bitwise OR.""" - if type(other) is not type(self): - self._raise_type_error(other, "|") - return type(self)(super().__or__(other)) - - def __ror__(self, other: Any) -> Self: - """Reverse bitwise OR.""" - return self.__or__(other) - - def __xor__(self, other: Any) -> Self: - """Forward bitwise XOR.""" - if type(other) is not type(self): - self._raise_type_error(other, "^") - return type(self)(super().__xor__(other)) - - def __rxor__(self, other: Any) -> Self: - """Reverse bitwise XOR.""" - return self.__xor__(other) - - def __lshift__(self, other: Any) -> Self: - """Forward left bit-shift.""" - if type(other) is not type(self): - self._raise_type_error(other, "<<") - return type(self)(super().__lshift__(other)) - - def __rlshift__(self, other: Any) -> Self: - """Reverse left bit-shift.""" - if type(other) is not type(self): - self._raise_type_error(other, "<<") - return type(self)(int(other) << int(self)) - - def __rshift__(self, other: Any) -> Self: - """Forward right bit-shift.""" - if type(other) is not type(self): - self._raise_type_error(other, ">>") - return type(self)(super().__rshift__(other)) - - def __rrshift__(self, other: Any) -> Self: - """Reverse right bit-shift.""" - if type(other) is not type(self): - self._raise_type_error(other, ">>") - return type(self)(int(other) >> int(self)) - - def __eq__(self, other: object) -> bool: - """Equality.""" - if type(other) is not type(self): - self._raise_type_error(other, "==") - return super().__eq__(other) - - def __ne__(self, other: object) -> bool: - """Inequality.""" - if type(other) is not type(self): - self._raise_type_error(other, "!=") - return super().__ne__(other) - - def __lt__(self, other: Any) -> bool: - """Less-than.""" - if type(other) is not type(self): - self._raise_type_error(other, "<") - return super().__lt__(other) - - def __le__(self, other: Any) -> bool: - """Less-than-or-equal.""" - if type(other) is not type(self): - self._raise_type_error(other, "<=") - return super().__le__(other) - - def __gt__(self, other: Any) -> bool: - """Greater-than.""" - if type(other) is not type(self): - self._raise_type_error(other, ">") - return super().__gt__(other) - - def __ge__(self, other: Any) -> bool: - """Greater-than-or-equal.""" - if type(other) is not type(self): - self._raise_type_error(other, ">=") - return super().__ge__(other) - - def __repr__(self) -> str: - """Official representation includes the subtype name.""" - return f"{type(self).__name__}({int(self)})" - - def __str__(self) -> str: - """Informal representation matches the underlying value.""" - return str(int(self)) - - def __hash__(self) -> int: - """Hash mixes in the concrete subtype so distinct widths never collide.""" - return hash((type(self), int(self))) - - def __index__(self) -> int: - """Return a plain integer for slicing and indexing.""" - return int(self) - - -class Uint8(BaseUint): - """A type representing an 8-bit unsigned integer (uint8).""" - - BITS = 8 - - -class Uint16(BaseUint): - """A type representing a 16-bit unsigned integer (uint16).""" - - BITS = 16 - - -class Uint32(BaseUint): - """A type representing a 32-bit unsigned integer (uint32).""" - - BITS = 32 - - -class Uint64(BaseUint): - """A type representing a 64-bit unsigned integer (uint64).""" - - BITS = 64 diff --git a/src/lean_spec/spec/ssz_types.py b/src/lean_spec/spec/ssz_types.py new file mode 100644 index 000000000..99c6d3f3e --- /dev/null +++ b/src/lean_spec/spec/ssz_types.py @@ -0,0 +1,92 @@ +"""SSZ shapes leanSpec declares itself: frozen values, camelCase JSON, its own byte widths.""" + +import ssz + +from lean_spec.base import CamelModel + +Bytes32 = ssz.Root +"""The root type itself, since a sibling 32-byte vector would refuse to compare with a root.""" + + +class ContainerInvariantError(Exception): + """A leanSpec container refuses a value its own invariant does not admit.""" + + +class Container(ssz.Container, CamelModel): + """Ordered SSZ struct, frozen, with the camelCase JSON every spec type shares.""" + + # Fork choice hands one state to every branch below a block root. + # A branch that could write through it would rewrite history for its siblings. + MUTABLE = False + + +class List[T: ssz.SSZType](ssz.List[T], CamelModel): + """Frozen SSZ list, bounded by a declared limit.""" + + MUTABLE = False + + +class Vector[T: ssz.SSZType](ssz.Vector[T], CamelModel): + """Frozen SSZ vector, holding a declared number of elements.""" + + MUTABLE = False + + +class BitList(ssz.BitList, CamelModel): + """Frozen SSZ bitlist, bounded by a declared limit.""" + + MUTABLE = False + + +class BitVector(ssz.BitVector, CamelModel): + """Frozen SSZ bitvector, holding a declared number of bits.""" + + MUTABLE = False + + +class ByteList(ssz.ByteList, CamelModel): + """Frozen SSZ byte list, bounded by a declared limit.""" + + MUTABLE = False + + +class Bytes4(ssz.ByteVector): + """Fixed-size byte array of exactly 4 bytes.""" + + LENGTH = 4 + + +class Bytes16(ssz.ByteVector): + """Fixed-size byte array of exactly 16 bytes (Poly1305 authentication tag).""" + + LENGTH = 16 + + +class Bytes20(ssz.ByteVector): + """Fixed-size byte array of exactly 20 bytes.""" + + LENGTH = 20 + + +class Bytes33(ssz.ByteVector): + """Fixed-size byte array of exactly 33 bytes (compressed secp256k1 public key).""" + + LENGTH = 33 + + +class Bytes52(ssz.ByteVector): + """Fixed-size byte array of exactly 52 bytes.""" + + LENGTH = 52 + + +class Bytes64(ssz.ByteVector): + """Fixed-size byte array of exactly 64 bytes (secp256k1 signature).""" + + LENGTH = 64 + + +class ByteList512KiB(ByteList): + """Variable-length byte list with a 512 KiB limit.""" + + LIMIT = 512 * 1024 diff --git a/tests/consensus/__init__.py b/tests/consensus/__init__.py new file mode 100644 index 000000000..178023b52 --- /dev/null +++ b/tests/consensus/__init__.py @@ -0,0 +1 @@ +"""Consensus specification test vectors.""" diff --git a/tests/consensus/lstar/__init__.py b/tests/consensus/lstar/__init__.py new file mode 100644 index 000000000..3d5b98659 --- /dev/null +++ b/tests/consensus/lstar/__init__.py @@ -0,0 +1 @@ +"""Consensus specification test vectors for the Lstar fork.""" diff --git a/tests/consensus/lstar/fork_choice/test_block_unknown_parent.py b/tests/consensus/lstar/fork_choice/test_block_unknown_parent.py index f1372fdb0..674494f9f 100644 --- a/tests/consensus/lstar/fork_choice/test_block_unknown_parent.py +++ b/tests/consensus/lstar/fork_choice/test_block_unknown_parent.py @@ -10,7 +10,7 @@ StoreChecks, ) from lean_spec.spec.forks import RejectionReason, Slot -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/fork_choice/test_checkpoint_sync.py b/tests/consensus/lstar/fork_choice/test_checkpoint_sync.py index 0b5144f5e..047c2bbe7 100644 --- a/tests/consensus/lstar/fork_choice/test_checkpoint_sync.py +++ b/tests/consensus/lstar/fork_choice/test_checkpoint_sync.py @@ -1,6 +1,7 @@ """Checkpoint sync (non-genesis anchor) tests.""" import pytest +from ssz import hash_tree_root from consensus_testing import ( AggregatedAttestationSpec, @@ -12,10 +13,9 @@ TickStep, build_anchor, ) -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Interval, RejectionReason, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.config import INTERVALS_PER_SLOT, SECONDS_PER_SLOT -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/fork_choice/test_gossip_aggregated_attestation_validation.py b/tests/consensus/lstar/fork_choice/test_gossip_aggregated_attestation_validation.py index 8ec9bd647..bad017678 100644 --- a/tests/consensus/lstar/fork_choice/test_gossip_aggregated_attestation_validation.py +++ b/tests/consensus/lstar/fork_choice/test_gossip_aggregated_attestation_validation.py @@ -14,7 +14,7 @@ ) from lean_spec.spec.forks import Interval, RejectionReason, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.config import GOSSIP_DISPARITY_INTERVALS -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/fork_choice/test_gossip_attestation_validation.py b/tests/consensus/lstar/fork_choice/test_gossip_attestation_validation.py index 5f8289de9..8b5db64e1 100644 --- a/tests/consensus/lstar/fork_choice/test_gossip_attestation_validation.py +++ b/tests/consensus/lstar/fork_choice/test_gossip_attestation_validation.py @@ -14,7 +14,7 @@ ) from lean_spec.spec.forks import Interval, RejectionReason, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.config import GOSSIP_DISPARITY_INTERVALS -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/ssz/test_basic_types.py b/tests/consensus/lstar/ssz/test_basic_types.py index 86c952ba7..7eaee9767 100644 --- a/tests/consensus/lstar/ssz/test_basic_types.py +++ b/tests/consensus/lstar/ssz/test_basic_types.py @@ -1,1158 +1,14 @@ -"""SSZ conformance test vectors for all non-container types.""" - -from typing import ClassVar +"""SSZ conformance test vectors for the types leanSpec declares itself.""" import pytest from consensus_testing import SSZTestFiller from lean_spec.node.networking.enr.eth2 import AttestationSubnets from lean_spec.spec.crypto.koalabear import Fp, P -from lean_spec.spec.ssz import ( - BaseBitlist, - BaseBitvector, - Boolean, - ByteList512KiB, - Bytes4, - Bytes32, - Bytes52, - Bytes64, - SSZList, - SSZVector, - Uint8, - Uint16, - Uint32, - Uint64, -) pytestmark = pytest.mark.valid_until("Lstar") -class SampleBitvector8(BaseBitvector): - """8-bit bitvector. Fits exactly in one byte of SSZ encoding.""" - - LENGTH: ClassVar[int] = 8 - - -class SampleBitvector64(BaseBitvector): - """64-bit bitvector. Spans multiple 32-byte Merkle chunks.""" - - LENGTH: ClassVar[int] = 64 - - -class SampleBitlist16(BaseBitlist): - """Bitlist allowing up to 16 bits. Exercises the length-delimiting sentinel bit.""" - - LIMIT: ClassVar[int] = 16 - - -class SampleUint16Vector3(SSZVector[Uint16]): - """Fixed-length vector of 3 two-byte elements (6 bytes total).""" - - LENGTH: ClassVar[int] = 3 - - -class SampleUint64Vector4(SSZVector[Uint64]): - """Fixed-length vector of 4 eight-byte elements (32 bytes, one full chunk).""" - - LENGTH: ClassVar[int] = 4 - - -class SampleUint32List16(SSZList[Uint32]): - """Variable-length list of up to 16 four-byte elements.""" - - LIMIT: ClassVar[int] = 16 - ELEMENT_TYPE = Uint32 - - -class SampleBytes32List8(SSZList[Bytes32]): - """Variable-length list of up to 8 fixed-size 32-byte elements.""" - - LIMIT: ClassVar[int] = 8 - ELEMENT_TYPE = Bytes32 - - -def test_boolean_false(ssz_test: SSZTestFiller) -> None: - """ - The boolean false round-trips through encoding unchanged. - - Given - ----- - - the boolean value false. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the encoding is the byte 0x00. - - the decoded value equals the original. - """ - ssz_test(type_name="Boolean", value=Boolean(False)) - - -def test_boolean_true(ssz_test: SSZTestFiller) -> None: - """ - The boolean true round-trips through encoding unchanged. - - Given - ----- - - the boolean value true. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the encoding is the byte 0x01. - - the decoded value equals the original. - """ - ssz_test(type_name="Boolean", value=Boolean(True)) - - -def test_uint8_zero(ssz_test: SSZTestFiller) -> None: - """ - A one-byte uint at its lower bound round-trips unchanged. - - Given - ----- - - the value 0 as a one-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint8", value=Uint8(0)) - - -def test_uint8_one(ssz_test: SSZTestFiller) -> None: - """ - The smallest non-zero one-byte uint round-trips unchanged. - - Given - ----- - - the value 1 as a one-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint8", value=Uint8(1)) - - -def test_uint8_mid(ssz_test: SSZTestFiller) -> None: - """ - A one-byte uint with its high bit set round-trips unchanged. - - Given - ----- - - the value 128 as a one-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint8", value=Uint8(128)) - - -def test_uint8_max(ssz_test: SSZTestFiller) -> None: - """ - A one-byte uint at its upper bound round-trips unchanged. - - Given - ----- - - the value 255 as a one-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint8", value=Uint8(2**8 - 1)) - - -def test_uint16_zero(ssz_test: SSZTestFiller) -> None: - """ - A two-byte uint at its lower bound round-trips unchanged. - - Given - ----- - - the value 0 as a two-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint16", value=Uint16(0)) - - -def test_uint16_one(ssz_test: SSZTestFiller) -> None: - """ - The smallest non-zero two-byte uint round-trips unchanged. - - Given - ----- - - the value 1 as a two-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint16", value=Uint16(1)) - - -def test_uint16_mid(ssz_test: SSZTestFiller) -> None: - """ - A two-byte uint with its high bit set round-trips unchanged. - - Given - ----- - - the value 32768 as a two-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the little-endian byte order is preserved. - """ - ssz_test(type_name="Uint16", value=Uint16(32768)) - - -def test_uint16_max(ssz_test: SSZTestFiller) -> None: - """ - A two-byte uint at its upper bound round-trips unchanged. - - Given - ----- - - the value 65535 as a two-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint16", value=Uint16(2**16 - 1)) - - -def test_uint32_zero(ssz_test: SSZTestFiller) -> None: - """ - A four-byte uint at its lower bound round-trips unchanged. - - Given - ----- - - the value 0 as a four-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint32", value=Uint32(0)) - - -def test_uint32_one(ssz_test: SSZTestFiller) -> None: - """ - The smallest non-zero four-byte uint round-trips unchanged. - - Given - ----- - - the value 1 as a four-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint32", value=Uint32(1)) - - -def test_uint32_mid(ssz_test: SSZTestFiller) -> None: - """ - A four-byte uint with its high bit set round-trips unchanged. - - Given - ----- - - the value 2147483648 as a four-byte uint (2^31). - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the four-byte little-endian layout is preserved. - """ - ssz_test(type_name="Uint32", value=Uint32(2147483648)) - - -def test_uint32_max(ssz_test: SSZTestFiller) -> None: - """ - A four-byte uint at its upper bound round-trips unchanged. - - Given - ----- - - the largest four-byte uint value (2^32 - 1). - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint32", value=Uint32(2**32 - 1)) - - -def test_uint64_zero(ssz_test: SSZTestFiller) -> None: - """ - An eight-byte uint at its lower bound round-trips unchanged. - - Given - ----- - - the value 0 as an eight-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint64", value=Uint64(0)) - - -def test_uint64_one(ssz_test: SSZTestFiller) -> None: - """ - The smallest non-zero eight-byte uint round-trips unchanged. - - Given - ----- - - the value 1 as an eight-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Uint64", value=Uint64(1)) - - -def test_uint64_mid(ssz_test: SSZTestFiller) -> None: - """ - An eight-byte uint with its high bit set round-trips unchanged. - - Given - ----- - - the value 2^63 as an eight-byte uint. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the eight-byte little-endian layout is preserved. - """ - ssz_test(type_name="Uint64", value=Uint64(2**63)) - - -def test_uint64_max(ssz_test: SSZTestFiller) -> None: - """ - An eight-byte uint at its upper bound round-trips unchanged. - - Given - ----- - - the largest eight-byte uint value (2^64 - 1). - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the encoding is eight bytes of 0xff. - """ - ssz_test(type_name="Uint64", value=Uint64(2**64 - 1)) - - -def test_bytes4_zero(ssz_test: SSZTestFiller) -> None: - """ - A four-byte array of zeros round-trips unchanged. - - Given - ----- - - a four-byte array of all-zero bytes. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Bytes4", value=Bytes4(b"\x00" * 4)) - - -def test_bytes4_typical(ssz_test: SSZTestFiller) -> None: - """ - A four-byte array with non-zero content round-trips unchanged. - - Given - ----- - - a four-byte array holding the bytes 0xdeadbeef. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Bytes4", value=Bytes4(b"\xde\xad\xbe\xef")) - - -def test_bytes32_zero(ssz_test: SSZTestFiller) -> None: - """ - A 32-byte array of zeros round-trips unchanged. - - Given - ----- - - a 32-byte array of all-zero bytes. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Bytes32", value=Bytes32.zero()) - - -def test_bytes32_typical(ssz_test: SSZTestFiller) -> None: - """ - A 32-byte array with uniform content round-trips unchanged. - - Given - ----- - - a 32-byte array of the repeated byte 0xab. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Bytes32", value=Bytes32(b"\xab" * 32)) - - -def test_bytes32_incremental(ssz_test: SSZTestFiller) -> None: - """ - A 32-byte array with distinct bytes round-trips unchanged. - - Given - ----- - - a 32-byte array holding the bytes 0x00 through 0x1f. - - every byte distinct, so a byte swap would be detected. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Bytes32", value=Bytes32(bytes(range(32)))) - - -def test_bytes52_zero(ssz_test: SSZTestFiller) -> None: - """ - A 52-byte array of zeros round-trips unchanged. - - Given - ----- - - a 52-byte array of all-zero bytes. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Bytes52", value=Bytes52.zero()) - - -def test_bytes52_typical(ssz_test: SSZTestFiller) -> None: - """ - A 52-byte array with uniform content round-trips unchanged. - - Given - ----- - - a 52-byte array of the repeated byte 0xcd. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Bytes52", value=Bytes52(b"\xcd" * 52)) - - -def test_bytes64_zero(ssz_test: SSZTestFiller) -> None: - """ - A 64-byte array of zeros round-trips unchanged. - - Given - ----- - - a 64-byte array of all-zero bytes. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Bytes64", value=Bytes64.zero()) - - -def test_bytes64_typical(ssz_test: SSZTestFiller) -> None: - """ - A 64-byte array with uniform content round-trips unchanged. - - Given - ----- - - a 64-byte array of the repeated byte 0xef. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="Bytes64", value=Bytes64(b"\xef" * 64)) - - -def test_bytelist_empty(ssz_test: SSZTestFiller) -> None: - """ - An empty byte list round-trips unchanged. - - Given - ----- - - a byte list with no content. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="ByteList512KiB", value=ByteList512KiB(data=b"")) - - -def test_bytelist_small(ssz_test: SSZTestFiller) -> None: - """ - A small byte list round-trips unchanged. - - Given - ----- - - a byte list holding four bytes. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="ByteList512KiB", value=ByteList512KiB(data=b"\x01\x02\x03\x04")) - - -def test_bytelist_medium(ssz_test: SSZTestFiller) -> None: - """ - A medium byte list round-trips unchanged. - - Given - ----- - - a byte list holding 256 bytes. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test(type_name="ByteList512KiB", value=ByteList512KiB(data=bytes(range(256)))) - - -def test_bitvector8_all_zero(ssz_test: SSZTestFiller) -> None: - """ - An eight-bit vector with all bits clear round-trips unchanged. - - Given - ----- - - an eight-bit vector whose bits are all clear. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the encoding is the byte 0x00. - """ - ssz_test( - type_name="SampleBitvector8", - value=SampleBitvector8(data=[Boolean(False)] * 8), - ) - - -def test_bitvector8_all_one(ssz_test: SSZTestFiller) -> None: - """ - An eight-bit vector with all bits set round-trips unchanged. - - Given - ----- - - an eight-bit vector whose bits are all set. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the encoding is the byte 0xff. - """ - ssz_test( - type_name="SampleBitvector8", - value=SampleBitvector8(data=[Boolean(True)] * 8), - ) - - -def test_bitvector8_mixed(ssz_test: SSZTestFiller) -> None: - """ - An eight-bit vector with alternating bits round-trips unchanged. - - Given - ----- - - an eight-bit vector with alternating set and clear bits. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the encoding is the byte 0x55. - """ - ssz_test( - type_name="SampleBitvector8", - value=SampleBitvector8( - data=[ - Boolean(True), - Boolean(False), - Boolean(True), - Boolean(False), - Boolean(True), - Boolean(False), - Boolean(True), - Boolean(False), - ] - ), - ) - - -def test_bitvector64_all_zero(ssz_test: SSZTestFiller) -> None: - """ - A 64-bit vector with all bits clear round-trips unchanged. - - Given - ----- - - a 64-bit vector whose bits are all clear. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the encoding is eight zero bytes. - """ - ssz_test( - type_name="SampleBitvector64", - value=SampleBitvector64(data=[Boolean(False)] * 64), - ) - - -def test_bitvector64_all_one(ssz_test: SSZTestFiller) -> None: - """ - A 64-bit vector with all bits set round-trips unchanged. - - Given - ----- - - a 64-bit vector whose bits are all set. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the encoding is eight bytes of 0xff. - """ - ssz_test( - type_name="SampleBitvector64", - value=SampleBitvector64(data=[Boolean(True)] * 64), - ) - - -def test_bitvector64_mixed(ssz_test: SSZTestFiller) -> None: - """ - A 64-bit vector with alternating bits round-trips unchanged. - - Given - ----- - - a 64-bit vector with alternating set and clear bits. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - bit ordering is preserved across byte boundaries. - """ - ssz_test( - type_name="SampleBitvector64", - value=SampleBitvector64(data=[Boolean(i % 2 == 0) for i in range(64)]), - ) - - -def test_bitlist_empty(ssz_test: SSZTestFiller) -> None: - """ - An empty bitlist round-trips unchanged. - - Given - ----- - - a bitlist with no bits. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the encoding is the sentinel-only byte 0x01. - """ - ssz_test( - type_name="SampleBitlist16", - value=SampleBitlist16(data=[]), - ) - - -def test_bitlist_single_true(ssz_test: SSZTestFiller) -> None: - """ - A bitlist with one set bit round-trips unchanged. - - Given - ----- - - a bitlist holding a single set bit. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the sentinel immediately follows the data bit. - """ - ssz_test( - type_name="SampleBitlist16", - value=SampleBitlist16(data=[Boolean(True)]), - ) - - -def test_bitlist_single_false(ssz_test: SSZTestFiller) -> None: - """ - A bitlist with one clear bit round-trips unchanged. - - Given - ----- - - a bitlist holding a single clear bit. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the sentinel is the only set bit in the byte. - """ - ssz_test( - type_name="SampleBitlist16", - value=SampleBitlist16(data=[Boolean(False)]), - ) - - -def test_bitlist_at_limit(ssz_test: SSZTestFiller) -> None: - """ - A bitlist filled to its limit round-trips unchanged. - - Given - ----- - - a bitlist filled to its 16-bit limit. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - - the sentinel lands in a new byte. - """ - ssz_test( - type_name="SampleBitlist16", - value=SampleBitlist16(data=[Boolean(True)] * 16), - ) - - -def test_bitlist_mixed(ssz_test: SSZTestFiller) -> None: - """ - A partially filled bitlist round-trips unchanged. - - Given - ----- - - a bitlist holding five mixed bits, below its 16-bit limit. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleBitlist16", - value=SampleBitlist16( - data=[ - Boolean(True), - Boolean(False), - Boolean(True), - Boolean(True), - Boolean(False), - ] - ), - ) - - -def test_uint16_vector3_zero(ssz_test: SSZTestFiller) -> None: - """ - A three-element uint vector of zeros round-trips unchanged. - - Given - ----- - - a vector of three two-byte uints, all zero. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleUint16Vector3", - value=SampleUint16Vector3(data=[Uint16(0), Uint16(0), Uint16(0)]), - ) - - -def test_uint16_vector3_typical(ssz_test: SSZTestFiller) -> None: - """ - A three-element uint vector with mixed values round-trips unchanged. - - Given - ----- - - a vector of three two-byte uints with mixed values. - - the last element at the maximum value (65535). - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleUint16Vector3", - value=SampleUint16Vector3(data=[Uint16(100), Uint16(200), Uint16(65535)]), - ) - - -def test_uint64_vector4_zero(ssz_test: SSZTestFiller) -> None: - """ - A four-element uint vector of zeros round-trips unchanged. - - Given - ----- - - a vector of four eight-byte uints, all zero. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleUint64Vector4", - value=SampleUint64Vector4(data=[Uint64(0), Uint64(0), Uint64(0), Uint64(0)]), - ) - - -def test_uint64_vector4_typical(ssz_test: SSZTestFiller) -> None: - """ - A four-element uint vector spanning the value range round-trips unchanged. - - Given - ----- - - a vector of four eight-byte uints spanning the full per-element range. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleUint64Vector4", - value=SampleUint64Vector4( - data=[ - Uint64(1), - Uint64(2**32), - Uint64(2**63), - Uint64(2**64 - 1), - ] - ), - ) - - -def test_uint32_list_empty(ssz_test: SSZTestFiller) -> None: - """ - An empty uint list round-trips unchanged. - - Given - ----- - - a uint list with no elements. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleUint32List16", - value=SampleUint32List16(data=[]), - ) - - -def test_uint32_list_single(ssz_test: SSZTestFiller) -> None: - """ - A uint list with one element round-trips unchanged. - - Given - ----- - - a uint list holding a single four-byte element. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleUint32List16", - value=SampleUint32List16(data=[Uint32(42)]), - ) - - -def test_uint32_list_multiple(ssz_test: SSZTestFiller) -> None: - """ - A uint list with three elements round-trips unchanged. - - Given - ----- - - a uint list of three four-byte elements spanning the value range. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleUint32List16", - value=SampleUint32List16(data=[Uint32(0), Uint32(100), Uint32(2**32 - 1)]), - ) - - -def test_bytes32_list_empty(ssz_test: SSZTestFiller) -> None: - """ - An empty 32-byte-element list round-trips unchanged. - - Given - ----- - - a list of 32-byte elements with no entries. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleBytes32List8", - value=SampleBytes32List8(data=[]), - ) - - -def test_bytes32_list_single(ssz_test: SSZTestFiller) -> None: - """ - A 32-byte-element list with one entry round-trips unchanged. - - Given - ----- - - a list holding a single 32-byte element. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleBytes32List8", - value=SampleBytes32List8(data=[Bytes32(b"\xaa" * 32)]), - ) - - -def test_bytes32_list_multiple(ssz_test: SSZTestFiller) -> None: - """ - A 32-byte-element list with three entries round-trips unchanged. - - Given - ----- - - a list holding three distinct 32-byte elements. - - When - ---- - - the value is encoded and then decoded. - - Then - ---- - - the decoded value equals the original. - """ - ssz_test( - type_name="SampleBytes32List8", - value=SampleBytes32List8( - data=[ - Bytes32(b"\x01" * 32), - Bytes32(b"\x02" * 32), - Bytes32.zero(), - ] - ), - ) - - def test_fp_zero(ssz_test: SSZTestFiller) -> None: """ The zero field element round-trips unchanged. diff --git a/tests/consensus/lstar/ssz/test_consensus_containers.py b/tests/consensus/lstar/ssz/test_consensus_containers.py index 98602a046..b69e10bb4 100644 --- a/tests/consensus/lstar/ssz/test_consensus_containers.py +++ b/tests/consensus/lstar/ssz/test_consensus_containers.py @@ -1,6 +1,7 @@ """SSZ conformance tests for consensus containers.""" import pytest +from ssz import Boolean, Uint64 from consensus_testing import SSZTestFiller from consensus_testing.keys import create_dummy_signature @@ -27,7 +28,7 @@ Validator, Validators, ) -from lean_spec.spec.ssz import Boolean, ByteList512KiB, Bytes32, Bytes52, Uint64 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32, Bytes52 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/ssz/test_decode_failure_smoke.py b/tests/consensus/lstar/ssz/test_decode_failure_smoke.py deleted file mode 100644 index 9012bbb20..000000000 --- a/tests/consensus/lstar/ssz/test_decode_failure_smoke.py +++ /dev/null @@ -1,43 +0,0 @@ -"""SSZ smoke test for the decode-failure fixture path.""" - -from typing import ClassVar - -import pytest - -from consensus_testing import ExpectedRejection, SSZTestFiller -from lean_spec.spec.forks import RejectionReason -from lean_spec.spec.ssz import BaseBitlist, Boolean - -pytestmark = pytest.mark.valid_until("Lstar") - - -class SmokeBitlist8(BaseBitlist): - """Small bitlist with an 8-bit limit, used only by the decode-failure smoke test.""" - - LIMIT: ClassVar[int] = 8 - - -def test_ssz_decode_failure_bitlist_exceeds_limit(ssz_test: SSZTestFiller) -> None: - """ - Decoding a bitlist whose contents imply too many bits is rejected. - - Given - ----- - - a bitlist type capped at eight bits. - - the input bytes 0x0010, which place the sentinel at bit sixteen. - - When - ---- - - the input is decoded into that type. - - Then - ---- - - decoding is rejected. - - the reason is that the implied bit-length exceeds the limit. - """ - ssz_test( - type_name="SmokeBitlist8", - value=SmokeBitlist8(data=[Boolean(False)]), - raw_bytes="0x0010", - expected_rejection=ExpectedRejection(reason=RejectionReason.DECODE_ERROR), - ) diff --git a/tests/consensus/lstar/ssz/test_decode_rejections.py b/tests/consensus/lstar/ssz/test_decode_rejections.py index 160cc2099..e99ab2cbe 100644 --- a/tests/consensus/lstar/ssz/test_decode_rejections.py +++ b/tests/consensus/lstar/ssz/test_decode_rejections.py @@ -3,22 +3,23 @@ from typing import ClassVar import pytest +from ssz import Boolean, Uint32 from consensus_testing import ExpectedRejection, SSZTestFiller from lean_spec.spec.forks import RejectionReason, ValidatorIndex from lean_spec.spec.forks.lstar.containers import Validator, Validators -from lean_spec.spec.ssz import BaseBitlist, BaseBitvector, Boolean, Bytes4, Bytes52, Uint32 +from lean_spec.spec.ssz_types import BitList, BitVector, Bytes4, Bytes52 pytestmark = pytest.mark.valid_until("Lstar") -class DecodeBitlist8(BaseBitlist): +class DecodeBitlist8(BitList): """Bitlist with an 8-bit limit, used to exercise bitlist-decode rejections.""" LIMIT: ClassVar[int] = 8 -class DecodeBitvector16(BaseBitvector): +class DecodeBitvector16(BitVector): """Fixed-width 16-bit bitvector, used to exercise fixed-width length checks.""" LENGTH: ClassVar[int] = 16 diff --git a/tests/consensus/lstar/ssz/test_merkleization_boundaries.py b/tests/consensus/lstar/ssz/test_merkleization_boundaries.py deleted file mode 100644 index e058f4c13..000000000 --- a/tests/consensus/lstar/ssz/test_merkleization_boundaries.py +++ /dev/null @@ -1,246 +0,0 @@ -"""SSZ: hash_tree_root vectors at bit / chunk size boundaries.""" - -from typing import ClassVar - -import pytest - -from consensus_testing import SSZTestFiller -from lean_spec.spec.ssz import BaseBitlist, BaseBitvector, Boolean, SSZList, Uint64 - -pytestmark = pytest.mark.valid_until("Lstar") - - -class BoundaryBitvector1(BaseBitvector): - """Single-bit vector. Minimal case: one bit occupies one byte.""" - - LENGTH: ClassVar[int] = 1 - - -class BoundaryBitvector7(BaseBitvector): - """Seven-bit vector. One byte with the high bit left as padding.""" - - LENGTH: ClassVar[int] = 7 - - -class BoundaryBitvector9(BaseBitvector): - """Nine-bit vector. Two bytes: the second carries a single bit.""" - - LENGTH: ClassVar[int] = 9 - - -class BoundaryBitvector255(BaseBitvector): - """Just below one Merkle chunk. Final chunk holds 31 bytes of data and 1 pad byte.""" - - LENGTH: ClassVar[int] = 255 - - -class BoundaryBitvector256(BaseBitvector): - """Exact Merkle-chunk boundary. One chunk, no padding.""" - - LENGTH: ClassVar[int] = 256 - - -class BoundaryBitvector257(BaseBitvector): - """Just above one Merkle chunk. Second chunk holds one bit and mix-ins padding.""" - - LENGTH: ClassVar[int] = 257 - - -class BoundaryBitlist256(BaseBitlist): - """ - Bitlist whose limit is exactly one Merkle chunk. - - When filled to the limit, the sentinel bit lands in a fresh byte. - """ - - LIMIT: ClassVar[int] = 256 - - -class BoundaryUint64List32(SSZList[Uint64]): - """Uint64 list with a 32-element cap. 3 elements span 24 bytes, shy of one chunk.""" - - LIMIT: ClassVar[int] = 32 - ELEMENT_TYPE = Uint64 - - -def test_bitvector_length_one_all_set(ssz_test: SSZTestFiller) -> None: - """ - A one-bit vector merkleizes to a stable root. - - Given - ----- - - a one-bit vector with its only bit set. - - When - ---- - - the value is merkleized. - - Then - ---- - - the root matches the minimal single-chunk layout. - """ - ssz_test( - type_name="BoundaryBitvector1", - value=BoundaryBitvector1(data=[Boolean(True)]), - ) - - -def test_bitvector_length_seven_all_set(ssz_test: SSZTestFiller) -> None: - """ - A seven-bit vector merkleizes to a stable root. - - Given - ----- - - a seven-bit vector with all bits set. - - one pad bit before the byte boundary. - - When - ---- - - the value is merkleized. - - Then - ---- - - the root matches the expected single-chunk layout. - """ - ssz_test( - type_name="BoundaryBitvector7", - value=BoundaryBitvector7(data=[Boolean(True)] * 7), - ) - - -def test_bitvector_length_nine_all_set(ssz_test: SSZTestFiller) -> None: - """ - A nine-bit vector merkleizes to a stable root. - - Given - ----- - - a nine-bit vector with all bits set. - - data that straddles the single-byte boundary. - - When - ---- - - the value is merkleized. - - Then - ---- - - the root matches the expected two-byte layout. - """ - ssz_test( - type_name="BoundaryBitvector9", - value=BoundaryBitvector9(data=[Boolean(True)] * 9), - ) - - -def test_bitvector_length_255_all_set(ssz_test: SSZTestFiller) -> None: - """ - A 255-bit vector merkleizes to a stable root. - - Given - ----- - - a 255-bit vector with all bits set. - - one bit shy of a full 32-byte chunk. - - When - ---- - - the value is merkleized. - - Then - ---- - - the root matches the expected single-chunk layout. - """ - ssz_test( - type_name="BoundaryBitvector255", - value=BoundaryBitvector255(data=[Boolean(True)] * 255), - ) - - -def test_bitvector_length_256_all_set(ssz_test: SSZTestFiller) -> None: - """ - A 256-bit vector merkleizes to a stable root. - - Given - ----- - - a 256-bit vector with all bits set. - - data that fills exactly one chunk with no padding. - - When - ---- - - the value is merkleized. - - Then - ---- - - the root matches the exact single-chunk layout. - """ - ssz_test( - type_name="BoundaryBitvector256", - value=BoundaryBitvector256(data=[Boolean(True)] * 256), - ) - - -def test_bitvector_length_257_all_set(ssz_test: SSZTestFiller) -> None: - """ - A 257-bit vector merkleizes to a stable root. - - Given - ----- - - a 257-bit vector with all bits set. - - one bit that spills into a second chunk. - - When - ---- - - the value is merkleized. - - Then - ---- - - the root matches the expected two-chunk layout. - """ - ssz_test( - type_name="BoundaryBitvector257", - value=BoundaryBitvector257(data=[Boolean(True)] * 257), - ) - - -def test_bitlist_filled_to_chunk_boundary_limit(ssz_test: SSZTestFiller) -> None: - """ - A bitlist filled to a chunk-edge limit merkleizes to a stable root. - - Given - ----- - - a bitlist capped at 256 bits, filled to its limit. - - a sentinel that lands at the start of a fresh byte. - - When - ---- - - the value is merkleized. - - Then - ---- - - the root matches the expected length-mixin ordering. - """ - ssz_test( - type_name="BoundaryBitlist256", - value=BoundaryBitlist256(data=[Boolean(True)] * 256), - ) - - -def test_uint64_list_with_misaligned_chunk_count(ssz_test: SSZTestFiller) -> None: - """ - A uint64 list whose bytes span a partial chunk merkleizes to a stable root. - - Given - ----- - - a uint64 list with three entries occupying 24 bytes. - - a length one byte shy of a full 32-byte chunk. - - When - ---- - - the value is merkleized. - - Then - ---- - - the root matches the expected zero-pad and length-mixin layout. - """ - ssz_test( - type_name="BoundaryUint64List32", - value=BoundaryUint64List32(data=[Uint64(1), Uint64(2), Uint64(3)]), - ) diff --git a/tests/consensus/lstar/ssz/test_networking_containers.py b/tests/consensus/lstar/ssz/test_networking_containers.py index 7ca6a5e81..fbd8359f0 100644 --- a/tests/consensus/lstar/ssz/test_networking_containers.py +++ b/tests/consensus/lstar/ssz/test_networking_containers.py @@ -9,7 +9,7 @@ Status, ) from lean_spec.spec.forks import Checkpoint, Slot -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/ssz/test_xmss_containers.py b/tests/consensus/lstar/ssz/test_xmss_containers.py index f662133da..8797fe5bb 100644 --- a/tests/consensus/lstar/ssz/test_xmss_containers.py +++ b/tests/consensus/lstar/ssz/test_xmss_containers.py @@ -1,6 +1,7 @@ """SSZ conformance tests for XMSS containers.""" import pytest +from ssz import Boolean, Uint64 from consensus_testing import SSZTestFiller from consensus_testing.keys import XmssKeyManager, create_dummy_signature @@ -19,7 +20,7 @@ MultiMessageAggregate, SingleMessageAggregate, ) -from lean_spec.spec.ssz import Boolean, ByteList512KiB, Bytes32, Uint64 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/state_transition/test_aggregation_bits.py b/tests/consensus/lstar/state_transition/test_aggregation_bits.py index b3bcec2ef..c1e044c6c 100644 --- a/tests/consensus/lstar/state_transition/test_aggregation_bits.py +++ b/tests/consensus/lstar/state_transition/test_aggregation_bits.py @@ -1,6 +1,7 @@ """State Transition: Aggregation Bits Validation""" import pytest +from ssz import Boolean from consensus_testing import ( AggregatedAttestationSpec, @@ -11,7 +12,6 @@ ) from lean_spec.spec.forks import AggregationBits, RejectionReason, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import JustificationRoots, JustificationValidators -from lean_spec.spec.ssz import Boolean pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/state_transition/test_attestation_chain_binding.py b/tests/consensus/lstar/state_transition/test_attestation_chain_binding.py index 1c29a36d2..5da052273 100644 --- a/tests/consensus/lstar/state_transition/test_attestation_chain_binding.py +++ b/tests/consensus/lstar/state_transition/test_attestation_chain_binding.py @@ -1,6 +1,7 @@ """State Transition: Attestation Chain Binding""" import pytest +from ssz import ZERO_ROOT from consensus_testing import ( AggregatedAttestationSpec, @@ -13,7 +14,7 @@ JustificationRoots, JustificationValidators, ) -from lean_spec.spec.ssz import ZERO_HASH, Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") @@ -61,7 +62,7 @@ def test_vote_with_zero_hash_head_root_is_skipped( slot=Slot(2), target_slot=Slot(1), target_root_label="block_1", - head_root=ZERO_HASH, + head_root=ZERO_ROOT, head_slot=Slot(1), ), ], diff --git a/tests/consensus/lstar/state_transition/test_block_processing.py b/tests/consensus/lstar/state_transition/test_block_processing.py index ad7bea4f5..c67e8486a 100644 --- a/tests/consensus/lstar/state_transition/test_block_processing.py +++ b/tests/consensus/lstar/state_transition/test_block_processing.py @@ -1,6 +1,7 @@ """State Transition: Block Processing""" import pytest +from ssz import Boolean from consensus_testing import ( BlockSpec, @@ -12,7 +13,7 @@ from lean_spec.spec.forks import RejectionReason, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import JustifiedSlots from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Boolean, Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/state_transition/test_finalization.py b/tests/consensus/lstar/state_transition/test_finalization.py index 001213bc9..471019a2b 100644 --- a/tests/consensus/lstar/state_transition/test_finalization.py +++ b/tests/consensus/lstar/state_transition/test_finalization.py @@ -1,6 +1,7 @@ """State Transition: Finalization""" import pytest +from ssz import Boolean, Uint64, hash_tree_root from consensus_testing import ( AggregatedAttestationSpec, @@ -9,7 +10,6 @@ StateTransitionTestFiller, build_genesis_state, ) -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import ( BlockHeader, @@ -21,7 +21,7 @@ State, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Boolean, Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/state_transition/test_genesis.py b/tests/consensus/lstar/state_transition/test_genesis.py index 6280162d3..d2c2aaccd 100644 --- a/tests/consensus/lstar/state_transition/test_genesis.py +++ b/tests/consensus/lstar/state_transition/test_genesis.py @@ -1,6 +1,7 @@ """State Transition: Genesis State""" import pytest +from ssz import Uint64, hash_tree_root from consensus_testing import ( AggregatedAttestationSpec, @@ -9,7 +10,6 @@ StateTransitionTestFiller, build_genesis_state, ) -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import VALIDATOR_REGISTRY_LIMIT, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestations, @@ -23,7 +23,7 @@ Validators, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Bytes32, Bytes52, Uint64 +from lean_spec.spec.ssz_types import Bytes32, Bytes52 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/state_transition/test_justification.py b/tests/consensus/lstar/state_transition/test_justification.py index 3b0eaa6aa..74afb4b53 100644 --- a/tests/consensus/lstar/state_transition/test_justification.py +++ b/tests/consensus/lstar/state_transition/test_justification.py @@ -1,6 +1,7 @@ """State Transition: Justification""" import pytest +from ssz import ZERO_ROOT, Boolean from consensus_testing import ( AggregatedAttestationSpec, @@ -15,7 +16,7 @@ JustificationValidators, JustifiedSlots, ) -from lean_spec.spec.ssz import ZERO_HASH, Boolean, Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") @@ -1257,7 +1258,7 @@ def test_attestation_with_zero_hash_source_root_is_skipped( slot=Slot(2), target_slot=Slot(1), target_root_label="block_1", - source_root=ZERO_HASH, + source_root=ZERO_ROOT, ), AggregatedAttestationSpec( validator_indices=[ @@ -1332,7 +1333,7 @@ def test_attestation_with_zero_hash_target_root_is_skipped( ], slot=Slot(2), target_slot=Slot(1), - target_root=ZERO_HASH, + target_root=ZERO_ROOT, ), AggregatedAttestationSpec( validator_indices=[ diff --git a/tests/consensus/lstar/state_transition/test_justification_accounting.py b/tests/consensus/lstar/state_transition/test_justification_accounting.py index fb4c37bb2..133db28fd 100644 --- a/tests/consensus/lstar/state_transition/test_justification_accounting.py +++ b/tests/consensus/lstar/state_transition/test_justification_accounting.py @@ -1,6 +1,7 @@ """State Transition: Justification Accounting""" import pytest +from ssz import Boolean from consensus_testing import ( AggregatedAttestationSpec, @@ -14,7 +15,7 @@ JustificationValidators, JustifiedSlots, ) -from lean_spec.spec.ssz import Boolean, Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/state_transition/test_justification_votes_length_mismatch.py b/tests/consensus/lstar/state_transition/test_justification_votes_length_mismatch.py index 13405abf4..68c828268 100644 --- a/tests/consensus/lstar/state_transition/test_justification_votes_length_mismatch.py +++ b/tests/consensus/lstar/state_transition/test_justification_votes_length_mismatch.py @@ -1,6 +1,7 @@ """State Transition: justification vote-list layout guard""" import pytest +from ssz import Boolean from consensus_testing import ( BlockSpec, @@ -10,7 +11,7 @@ ) from lean_spec.spec.forks import RejectionReason, Slot from lean_spec.spec.forks.lstar.containers import JustificationRoots, JustificationValidators -from lean_spec.spec.ssz import Boolean, Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/state_transition/test_skipped_slot_history.py b/tests/consensus/lstar/state_transition/test_skipped_slot_history.py index 174823f70..bbe5b6866 100644 --- a/tests/consensus/lstar/state_transition/test_skipped_slot_history.py +++ b/tests/consensus/lstar/state_transition/test_skipped_slot_history.py @@ -1,6 +1,7 @@ """State Transition: Skipped Slot History""" import pytest +from ssz import ZERO_ROOT, Uint64, hash_tree_root from consensus_testing import ( BlockSpec, @@ -8,11 +9,9 @@ StateTransitionTestFiller, build_genesis_state, ) -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import HistoricalBlockHashes from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import ZERO_HASH, Uint64 pytestmark = pytest.mark.valid_until("Lstar") @@ -52,7 +51,7 @@ def test_multi_slot_gap_materializes_zero_hash_history( slot=Slot(1), proposer_index=ValidatorIndex.proposer_for_slot(Slot(1), Uint64(len(pre.validators))), parent_root=anchor_root, - state_root=ZERO_HASH, + state_root=ZERO_ROOT, body=spec.block_body_class(attestations=spec.aggregated_attestations_class(data=[])), ) state_after_block_1 = spec.process_block(anchor_state, block_1) @@ -68,7 +67,7 @@ def test_multi_slot_gap_materializes_zero_hash_history( post=StateExpectation( slot=Slot(4), historical_block_hashes=HistoricalBlockHashes( - data=[anchor_root, parent_root, ZERO_HASH, ZERO_HASH] + data=[anchor_root, parent_root, ZERO_ROOT, ZERO_ROOT] ), ), ) diff --git a/tests/consensus/lstar/state_transition/test_small_validator_quorums.py b/tests/consensus/lstar/state_transition/test_small_validator_quorums.py index 150fff1bb..4755b5858 100644 --- a/tests/consensus/lstar/state_transition/test_small_validator_quorums.py +++ b/tests/consensus/lstar/state_transition/test_small_validator_quorums.py @@ -1,6 +1,7 @@ """State Transition: Small Validator Quorums""" import pytest +from ssz import Boolean from consensus_testing import ( AggregatedAttestationSpec, @@ -15,7 +16,6 @@ JustificationValidators, JustifiedSlots, ) -from lean_spec.spec.ssz import Boolean pytestmark = pytest.mark.valid_until("Lstar") diff --git a/tests/consensus/lstar/state_transition/test_zero_hash_justification_root.py b/tests/consensus/lstar/state_transition/test_zero_hash_justification_root.py index 88e7be0d1..0f2f176a8 100644 --- a/tests/consensus/lstar/state_transition/test_zero_hash_justification_root.py +++ b/tests/consensus/lstar/state_transition/test_zero_hash_justification_root.py @@ -1,6 +1,7 @@ """State Transition: zero-hash justification root guard""" import pytest +from ssz import ZERO_ROOT, Boolean from consensus_testing import ( BlockSpec, @@ -10,7 +11,6 @@ ) from lean_spec.spec.forks import RejectionReason, Slot from lean_spec.spec.forks.lstar.containers import JustificationRoots, JustificationValidators -from lean_spec.spec.ssz import ZERO_HASH, Boolean pytestmark = pytest.mark.valid_until("Lstar") @@ -42,7 +42,7 @@ def test_zero_hash_tracked_justification_root_rejects_block( state_transition_test( pre=build_genesis_state(num_validators=4).model_copy( update={ - "justifications_roots": JustificationRoots(data=[ZERO_HASH]), + "justifications_roots": JustificationRoots(data=[ZERO_ROOT]), "justifications_validators": JustificationValidators( data=[Boolean(False), Boolean(False), Boolean(False), Boolean(False)] ), diff --git a/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py b/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py index f1f33b1b3..49b2fe811 100644 --- a/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py +++ b/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py @@ -13,7 +13,7 @@ ) from lean_spec.spec.forks import Checkpoint, RejectionReason, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import AttestationData -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = [pytest.mark.valid_until("Lstar"), pytest.mark.real_crypto] diff --git a/tests/consensus/lstar/verify_proofs/test_multi_message_valid.py b/tests/consensus/lstar/verify_proofs/test_multi_message_valid.py index 84e847658..66f70b82e 100644 --- a/tests/consensus/lstar/verify_proofs/test_multi_message_valid.py +++ b/tests/consensus/lstar/verify_proofs/test_multi_message_valid.py @@ -5,7 +5,7 @@ from consensus_testing import VerifyMultiMessageProofsTestFiller from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import AttestationData -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = [pytest.mark.valid_until("Lstar"), pytest.mark.real_crypto] diff --git a/tests/consensus/lstar/verify_proofs/test_single_message_invalid.py b/tests/consensus/lstar/verify_proofs/test_single_message_invalid.py index f2ff8c62a..0453424d4 100644 --- a/tests/consensus/lstar/verify_proofs/test_single_message_invalid.py +++ b/tests/consensus/lstar/verify_proofs/test_single_message_invalid.py @@ -11,7 +11,7 @@ ) from lean_spec.spec.forks import Checkpoint, RejectionReason, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import AttestationData -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = [pytest.mark.valid_until("Lstar"), pytest.mark.real_crypto] diff --git a/tests/consensus/lstar/verify_proofs/test_single_message_recursion.py b/tests/consensus/lstar/verify_proofs/test_single_message_recursion.py index 1c2103a37..aadda4b77 100644 --- a/tests/consensus/lstar/verify_proofs/test_single_message_recursion.py +++ b/tests/consensus/lstar/verify_proofs/test_single_message_recursion.py @@ -10,7 +10,7 @@ ) from lean_spec.spec.forks import Checkpoint, RejectionReason, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import AttestationData -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = [pytest.mark.valid_until("Lstar"), pytest.mark.real_crypto] diff --git a/tests/consensus/lstar/verify_proofs/test_single_message_valid.py b/tests/consensus/lstar/verify_proofs/test_single_message_valid.py index 846348479..0a8069341 100644 --- a/tests/consensus/lstar/verify_proofs/test_single_message_valid.py +++ b/tests/consensus/lstar/verify_proofs/test_single_message_valid.py @@ -5,7 +5,7 @@ from consensus_testing import VerifySingleMessageProofsTestFiller from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import AttestationData -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 pytestmark = [pytest.mark.valid_until("Lstar"), pytest.mark.real_crypto] diff --git a/tests/consensus/lstar/verify_signatures/test_structural_rejections.py b/tests/consensus/lstar/verify_signatures/test_structural_rejections.py index b2c576f12..4a92f6651 100644 --- a/tests/consensus/lstar/verify_signatures/test_structural_rejections.py +++ b/tests/consensus/lstar/verify_signatures/test_structural_rejections.py @@ -1,6 +1,7 @@ """Signature verification rejects merged proofs that break structural invariants.""" import pytest +from ssz import hash_tree_root from consensus_testing import ( AggregatedAttestationSpec, @@ -14,7 +15,6 @@ build_anchor, build_genesis_state, ) -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import RejectionReason, Slot, ValidatorIndex pytestmark = [pytest.mark.valid_until("Lstar"), pytest.mark.real_crypto] diff --git a/tests/interop/helpers/node_runner.py b/tests/interop/helpers/node_runner.py index 27ec95fbd..9d5fa42a9 100644 --- a/tests/interop/helpers/node_runner.py +++ b/tests/interop/helpers/node_runner.py @@ -12,6 +12,8 @@ from dataclasses import dataclass, field from typing import cast +from ssz import Uint64 + from lean_spec.node.networking import PeerId from lean_spec.node.networking.client import LiveNetworkEventSource from lean_spec.node.networking.gossipsub.types import TopicId @@ -27,7 +29,7 @@ from lean_spec.spec.forks.lstar.config import ATTESTATION_COMMITTEE_COUNT from lean_spec.spec.forks.lstar.containers import Validator, Validators from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Bytes52, Uint64 +from lean_spec.spec.ssz_types import Bytes52 from tests.interop.helpers.diagnostics import PipelineDiagnostics from tests.interop.helpers.port_allocator import PortAllocator diff --git a/tests/node/api/test_server.py b/tests/node/api/test_server.py index ce6ec86bb..2b8915792 100644 --- a/tests/node/api/test_server.py +++ b/tests/node/api/test_server.py @@ -3,10 +3,10 @@ from __future__ import annotations import httpx +from ssz import hash_tree_root from consensus_testing import store_backed_signed_block_getter from lean_spec.node.api import ApiServer, ApiServerConfig -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import SignedBlock from lean_spec.spec.forks.lstar import Store from tests.node.api.conftest import AggregatorRoleStub diff --git a/tests/node/chain/test_clock.py b/tests/node/chain/test_clock.py index c9320e344..678b4726a 100644 --- a/tests/node/chain/test_clock.py +++ b/tests/node/chain/test_clock.py @@ -5,11 +5,11 @@ from unittest.mock import patch import pytest +from ssz import Uint64 from lean_spec.node.chain import SlotClock from lean_spec.spec.forks import Interval, Slot from lean_spec.spec.forks.lstar.config import INTERVALS_PER_SLOT -from lean_spec.spec.ssz import Uint64 def clock_at(genesis_seconds: int, now_seconds: float) -> SlotClock: diff --git a/tests/node/chain/test_service.py b/tests/node/chain/test_service.py index cb3902a86..8dfe8cb87 100644 --- a/tests/node/chain/test_service.py +++ b/tests/node/chain/test_service.py @@ -7,6 +7,7 @@ from unittest.mock import patch import pytest +from ssz import Boolean, Uint64 from consensus_testing import build_genesis_store from lean_spec.node.chain import SlotClock @@ -20,7 +21,7 @@ SingleMessageAggregate, Store, ) -from lean_spec.spec.ssz import Boolean, ByteList512KiB, Bytes32, Uint64 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32 # One interval lasts this many wall-clock seconds. # diff --git a/tests/node/networking/client/event_source/test_live.py b/tests/node/networking/client/event_source/test_live.py index ea9e6c196..0eab4d426 100644 --- a/tests/node/networking/client/event_source/test_live.py +++ b/tests/node/networking/client/event_source/test_live.py @@ -24,7 +24,7 @@ ) from lean_spec.spec.forks import Checkpoint, Slot from lean_spec.spec.forks.lstar.containers import SignedBlock -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 FORK_DIGEST = "0xaabbccdd" diff --git a/tests/node/networking/client/test_reqresp_client.py b/tests/node/networking/client/test_reqresp_client.py index 8f7acbb3a..b8f3630cc 100644 --- a/tests/node/networking/client/test_reqresp_client.py +++ b/tests/node/networking/client/test_reqresp_client.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field import pytest +from ssz import Uint64, hash_tree_root from consensus_testing import make_test_block, make_test_status from lean_spec.node.networking.client.reqresp_client import ( @@ -25,7 +26,6 @@ Status, ) from lean_spec.node.networking.transport import PeerId -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar.containers import ( AggregatedAttestations, @@ -34,7 +34,7 @@ MultiMessageAggregate, SignedBlock, ) -from lean_spec.spec.ssz import ByteList512KiB, Bytes32, Uint64 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32 @dataclass diff --git a/tests/node/networking/enr/test_enr.py b/tests/node/networking/enr/test_enr.py index b17e20cbc..1198f351e 100644 --- a/tests/node/networking/enr/test_enr.py +++ b/tests/node/networking/enr/test_enr.py @@ -20,12 +20,13 @@ Prehashed, decode_dss_signature, ) +from ssz import SSZValueError, Uint64 from lean_spec.node.networking.enr import ENR, keys from lean_spec.node.networking.enr.enr import ENR_PREFIX from lean_spec.node.networking.enr.rlp import RLPItem, encode_rlp from lean_spec.node.networking.types import Port, SeqNumber -from lean_spec.spec.ssz import Bytes4, Bytes64, SSZValueError, Uint64 +from lean_spec.spec.ssz_types import Bytes4, Bytes64 # From: https://eips.ethereum.org/EIPS/eip-778 # @@ -453,7 +454,7 @@ def test_construction_fails_for_wrong_signature_length(self) -> None: seq=SeqNumber(1), pairs={keys.ID: b"v4", keys.SECP256K1: b"\x02" + b"\x00" * 32}, ) - assert str(exception_info.value) == "Bytes64 requires exactly 64 bytes, got 63" + assert str(exception_info.value) == "Bytes64 holds exactly 64 bytes, got 63" class TestMultiaddrGeneration: diff --git a/tests/node/networking/enr/test_eth2.py b/tests/node/networking/enr/test_eth2.py index 3ed08431f..bc1f59a55 100644 --- a/tests/node/networking/enr/test_eth2.py +++ b/tests/node/networking/enr/test_eth2.py @@ -2,6 +2,7 @@ import pytest from pydantic import ValidationError +from ssz import Uint64 from lean_spec.node.networking.enr import Eth2Data from lean_spec.node.networking.enr.eth2 import ( @@ -10,7 +11,6 @@ ) from lean_spec.node.networking.types import ForkDigest, Version from lean_spec.spec.forks import SubnetId -from lean_spec.spec.ssz import Uint64 class TestEth2Data: diff --git a/tests/node/networking/reqresp/test_handler.py b/tests/node/networking/reqresp/test_handler.py index 87849f0d1..eafb9c9e9 100644 --- a/tests/node/networking/reqresp/test_handler.py +++ b/tests/node/networking/reqresp/test_handler.py @@ -7,6 +7,7 @@ from typing import Final import pytest +from ssz import SSZValueError, Uint64, hash_tree_root from consensus_testing import make_test_block, make_test_status from lean_spec.node.networking.config import ( @@ -37,11 +38,9 @@ from lean_spec.node.networking.types import ProtocolId from lean_spec.node.networking.varint import encode_varint from lean_spec.node.snappy import frame_compress -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Checkpoint, Slot from lean_spec.spec.forks.lstar.containers import SignedBlock -from lean_spec.spec.ssz import Bytes32, Uint64 -from lean_spec.spec.ssz.exceptions import SSZSerializationError +from lean_spec.spec.ssz_types import Bytes32 @dataclass @@ -1393,23 +1392,23 @@ class TestBlocksByRangeRequestMalformedPayloads: def test_truncated_payload_rejected(self) -> None: """Payload shorter than 16 bytes is rejected.""" truncated = b"\x01" * 15 # 15 bytes, need 16 - with pytest.raises(SSZSerializationError): + with pytest.raises(SSZValueError): BlocksByRangeRequest.decode_bytes(truncated) def test_empty_payload_rejected(self) -> None: """Zero-length payload is rejected.""" - with pytest.raises(SSZSerializationError): + with pytest.raises(SSZValueError): BlocksByRangeRequest.decode_bytes(b"") def test_single_byte_rejected(self) -> None: """Single byte payload is rejected.""" - with pytest.raises(SSZSerializationError): + with pytest.raises(SSZValueError): BlocksByRangeRequest.decode_bytes(b"\x00") def test_eight_byte_payload_rejected(self) -> None: """8 bytes (half-payload, single field) is rejected.""" partial = (100).to_bytes(8, "little") - with pytest.raises(SSZSerializationError): + with pytest.raises(SSZValueError): BlocksByRangeRequest.decode_bytes(partial) diff --git a/tests/node/networking/reqresp/test_message.py b/tests/node/networking/reqresp/test_message.py index e097dfeb6..9acb4faee 100644 --- a/tests/node/networking/reqresp/test_message.py +++ b/tests/node/networking/reqresp/test_message.py @@ -3,6 +3,8 @@ from __future__ import annotations import pytest +from pydantic import ValidationError +from ssz import Uint64 from lean_spec.node.networking.config import MAX_REQUEST_BLOCKS from lean_spec.node.networking.reqresp.message import ( @@ -16,8 +18,7 @@ ) from lean_spec.node.networking.types import ProtocolId from lean_spec.spec.forks import Checkpoint, Slot -from lean_spec.spec.ssz import Bytes32, Uint64 -from lean_spec.spec.ssz.exceptions import SSZValueError +from lean_spec.spec.ssz_types import Bytes32 class TestProtocolIdentifiers: @@ -81,10 +82,12 @@ def test_accepts_list_at_limit(self) -> None: def test_rejects_list_over_limit(self) -> None: """A list one element over the limit is rejected with the full message.""" - with pytest.raises(SSZValueError) as exception_info: + with pytest.raises(ValidationError) as exception_info: RequestedBlockRoots(data=[Bytes32(b"\x00" * 32)] * (MAX_REQUEST_BLOCKS + 1)) - assert str(exception_info.value) == ( - f"RequestedBlockRoots exceeds limit of {MAX_REQUEST_BLOCKS}, " + # A field validator wraps the refusal, so the assertion reads the original it carries. + [validation_error] = exception_info.value.errors() + assert str(validation_error["ctx"]["error"]) == ( + f"RequestedBlockRoots holds at most {MAX_REQUEST_BLOCKS} elements, " f"got {MAX_REQUEST_BLOCKS + 1}" ) diff --git a/tests/node/networking/service/test_events.py b/tests/node/networking/service/test_events.py index 63a77916a..08e68d825 100644 --- a/tests/node/networking/service/test_events.py +++ b/tests/node/networking/service/test_events.py @@ -27,7 +27,7 @@ SignedAggregatedAttestation, SignedAttestation, ) -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 FORK_DIGEST = "0x12345678" diff --git a/tests/node/networking/service/test_service.py b/tests/node/networking/service/test_service.py index f8cd8c387..229d9d1da 100644 --- a/tests/node/networking/service/test_service.py +++ b/tests/node/networking/service/test_service.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from ssz import hash_tree_root from consensus_testing import ( MockEventSource, @@ -33,14 +34,13 @@ from lean_spec.node.networking.types import ConnectionState from lean_spec.node.snappy import compress from lean_spec.node.sync.states import SyncState -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Checkpoint, Slot, SubnetId, ValidatorIndex from lean_spec.spec.forks.lstar.containers import ( AttestationData, SignedAggregatedAttestation, SignedAttestation, ) -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 FORK_DIGEST = "0x12345678" diff --git a/tests/node/networking/test_peer.py b/tests/node/networking/test_peer.py index 9af0acd19..e69dea914 100644 --- a/tests/node/networking/test_peer.py +++ b/tests/node/networking/test_peer.py @@ -5,7 +5,7 @@ from lean_spec.node.networking.reqresp import Status from lean_spec.node.networking.types import ConnectionState, Direction, Multiaddr from lean_spec.spec.forks import Checkpoint, Slot -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 def peer(name: str) -> PeerId: diff --git a/tests/node/networking/test_types.py b/tests/node/networking/test_types.py index 2d2926a58..27d5cc599 100644 --- a/tests/node/networking/test_types.py +++ b/tests/node/networking/test_types.py @@ -3,6 +3,7 @@ from __future__ import annotations import pytest +from ssz import SSZValueError, Uint16, Uint64 from lean_spec.node.networking.types import ( ConnectionState, @@ -16,7 +17,7 @@ SeqNumber, Version, ) -from lean_spec.spec.ssz import Bytes4, Bytes32, SSZValueError, Uint16, Uint64 +from lean_spec.spec.ssz_types import Bytes4, Bytes32 class TestConnectionState: @@ -52,7 +53,7 @@ def test_wrong_length_rejected(self) -> None: """A byte count other than four is rejected with the full length message.""" with pytest.raises(SSZValueError) as exception_info: DomainType(b"\x00" * 5) - assert str(exception_info.value) == "DomainType requires exactly 4 bytes, got 5" + assert str(exception_info.value) == "DomainType holds exactly 4 bytes, got 5" def test_encode_decode_roundtrip(self) -> None: """SSZ encoding then decoding reproduces the original value.""" @@ -73,7 +74,7 @@ def test_wrong_length_rejected(self) -> None: """A byte count other than thirty-two is rejected with the full length message.""" with pytest.raises(SSZValueError) as exception_info: NodeId(b"\x00" * 31) - assert str(exception_info.value) == "NodeId requires exactly 32 bytes, got 31" + assert str(exception_info.value) == "NodeId holds exactly 32 bytes, got 31" def test_encode_decode_roundtrip(self) -> None: """SSZ encoding then decoding reproduces the original value.""" @@ -94,11 +95,16 @@ def test_wrong_length_rejected(self) -> None: """A byte count other than four is rejected with the full length message.""" with pytest.raises(SSZValueError) as exception_info: ForkDigest(b"\x00" * 3) - assert str(exception_info.value) == "ForkDigest requires exactly 4 bytes, got 3" + assert str(exception_info.value) == "ForkDigest holds exactly 4 bytes, got 3" - def test_hash_distinguishes_from_other_bytes4_newtype(self) -> None: - """Equal bytes in a sibling four-byte newtype hash differently from a ForkDigest.""" - assert hash(ForkDigest(b"\x12\x34\x56\x78")) != hash(Version(b"\x12\x34\x56\x78")) + def test_refuses_comparison_with_another_bytes4_newtype(self) -> None: + """Equal bytes in a sibling four-byte newtype refuse to compare with a ForkDigest.""" + with pytest.raises(TypeError) as exception_info: + bool(ForkDigest(b"\x12\x34\x56\x78") == Version(b"\x12\x34\x56\x78")) + assert ( + str(exception_info.value) + == "Unsupported operand type(s) for ==: 'ForkDigest' and 'Version'" + ) class TestVersion: @@ -114,7 +120,7 @@ def test_wrong_length_rejected(self) -> None: """A byte count other than four is rejected with the full length message.""" with pytest.raises(SSZValueError) as exception_info: Version(b"\x00" * 8) - assert str(exception_info.value) == "Version requires exactly 4 bytes, got 8" + assert str(exception_info.value) == "Version holds exactly 4 bytes, got 8" def test_repr_names_the_newtype(self) -> None: """The official representation labels the value with its own type name.""" @@ -140,7 +146,7 @@ def test_above_maximum_rejected(self) -> None: SeqNumber(2**64) assert ( str(exception_info.value) - == "18446744073709551616 out of range for SeqNumber [0, 18446744073709551615]" + == "18446744073709551616 is out of range for SeqNumber [0, 18446744073709551615]" ) def test_equality_rejects_a_different_uint_newtype(self) -> None: @@ -170,15 +176,11 @@ def test_above_maximum_rejected(self) -> None: """A port past the sixteen-bit ceiling is rejected with the full range message.""" with pytest.raises(SSZValueError) as exception_info: Port(65536) - assert str(exception_info.value) == "65536 out of range for Port [0, 65535]" + assert str(exception_info.value) == "65536 is out of range for Port [0, 65535]" - def test_equality_rejects_the_plain_uint16_base(self) -> None: - """Comparing against the unwrapped base integer raises with the full operand message.""" - with pytest.raises(TypeError) as exception_info: - bool(Port(9000) == Uint16(9000)) - assert ( - str(exception_info.value) == "Unsupported operand type(s) for ==: 'Port' and 'Uint16'" - ) + def test_equality_admits_the_plain_uint16_base(self) -> None: + """A port compares equal to the same number spelled in the type it narrows.""" + assert Port(9000) == Uint16(9000) class TestProtocolId: diff --git a/tests/node/storage/test_sqlite.py b/tests/node/storage/test_sqlite.py index 8f0408da0..6034a2063 100644 --- a/tests/node/storage/test_sqlite.py +++ b/tests/node/storage/test_sqlite.py @@ -7,6 +7,7 @@ from pathlib import Path import pytest +from ssz import Uint64, hash_tree_root from lean_spec.node.storage import ( SQLiteDatabase, @@ -14,7 +15,6 @@ StorageReadError, StorageWriteError, ) -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar import State from lean_spec.spec.forks.lstar.containers import ( @@ -22,7 +22,7 @@ Block, BlockBody, ) -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 @pytest.fixture @@ -354,7 +354,8 @@ def test_corrupt_block_data_raises_corruption_error(self, db: SQLiteDatabase) -> with pytest.raises(StorageCorruptionError) as exception_info: db.get_block(root) assert str(exception_info.value) == ( - f"Corrupt block data for root {root.hex()}: ValidatorIndex: expected 8 bytes, got 5" + f"Corrupt block data for root {root.hex()}: " + "proposer_index: ValidatorIndex needs 8 bytes, the input holds 5" ) def test_corrupt_state_data_raises_corruption_error(self, db: SQLiteDatabase) -> None: @@ -371,7 +372,8 @@ def test_corrupt_state_data_raises_corruption_error(self, db: SQLiteDatabase) -> with pytest.raises(StorageCorruptionError) as exception_info: db.get_state(root) assert str(exception_info.value) == ( - f"Corrupt state data for block root {root.hex()}: Slot: expected 8 bytes, got 5" + f"Corrupt state data for block root {root.hex()}: " + "slot: Slot needs 8 bytes, the input holds 5" ) def test_corrupt_checkpoint_data_raises_corruption_error(self, db: SQLiteDatabase) -> None: @@ -386,7 +388,7 @@ def test_corrupt_checkpoint_data_raises_corruption_error(self, db: SQLiteDatabas with pytest.raises(StorageCorruptionError) as exception_info: db.get_justified_checkpoint() assert str(exception_info.value) == ( - "Corrupt justified checkpoint data: Bytes32: expected 32 bytes, got 13" + "Corrupt justified checkpoint data: root: Root needs 32 bytes, the input holds 13" ) def test_read_after_close_raises(self, db: SQLiteDatabase) -> None: diff --git a/tests/node/sync/conftest.py b/tests/node/sync/conftest.py index 2635045ee..3689d9152 100644 --- a/tests/node/sync/conftest.py +++ b/tests/node/sync/conftest.py @@ -10,7 +10,7 @@ from lean_spec.node.networking.reqresp.message import Status from lean_spec.spec.forks import Checkpoint, Slot -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 @pytest.fixture diff --git a/tests/node/sync/test_backfill_sync.py b/tests/node/sync/test_backfill_sync.py index 634697a71..07eb43516 100644 --- a/tests/node/sync/test_backfill_sync.py +++ b/tests/node/sync/test_backfill_sync.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field import pytest +from ssz import Uint64 from consensus_testing import MockNetworkRequester, make_signed_block from lean_spec.node.networking import PeerId @@ -23,7 +24,7 @@ SyncPeer, ) from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 @dataclass @@ -445,7 +446,7 @@ async def test_range_sync_triggered_by_gap_above_head( # First call fetches root_100 by root. # That returns block_100; its parent (block_50) is unknown, triggering # the gap path: range fetch (head+1=50, count=100-50=50) covers block_50. - # block_50's parent is ZERO_HASH which IS in the store, so recursion stops. + # block_50's parent is ZERO_ROOT which IS in the store, so recursion stops. assert network.root_request_log == [(peer_id, [root_100])] assert network.range_request_log == [(peer_id, Slot(50), Uint64(50))] diff --git a/tests/node/sync/test_block_cache.py b/tests/node/sync/test_block_cache.py index 36b77e083..8afcf2c62 100644 --- a/tests/node/sync/test_block_cache.py +++ b/tests/node/sync/test_block_cache.py @@ -2,13 +2,14 @@ from __future__ import annotations +from ssz import hash_tree_root + from consensus_testing import make_signed_block from lean_spec.node.networking import PeerId from lean_spec.node.sync.block_cache import BlockCache, PendingBlock from lean_spec.node.sync.config import MAX_CACHED_BLOCKS -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Slot, ValidatorIndex -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 class TestPendingBlock: diff --git a/tests/node/sync/test_checkpoint_sync.py b/tests/node/sync/test_checkpoint_sync.py index 47c605854..68b3056d9 100644 --- a/tests/node/sync/test_checkpoint_sync.py +++ b/tests/node/sync/test_checkpoint_sync.py @@ -6,6 +6,7 @@ import httpx import pytest +from ssz import hash_tree_root from consensus_testing import store_backed_signed_block_getter from lean_spec.node.api import ApiServer, ApiServerConfig @@ -17,7 +18,6 @@ fetch_finalized_state, verify_checkpoint_state, ) -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import VALIDATOR_REGISTRY_LIMIT, Slot from lean_spec.spec.forks.lstar import State, Store from lean_spec.spec.forks.lstar.containers import Validators @@ -76,7 +76,7 @@ async def test_state_without_validators_fails_verification(self, genesis_state: async def test_state_exceeding_validator_limit_fails(self) -> None: """State with more validators than VALIDATOR_REGISTRY_LIMIT fails.""" - # Use a mock because SSZList enforces LIMIT at construction time, + # Use a mock because List enforces LIMIT at construction time, # preventing creation of a real State with too many validators. mock_state = MagicMock() mock_state.slot = Slot(0) @@ -171,7 +171,7 @@ async def test_corrupt_ssz_raises_checkpoint_sync_error(self) -> None: ): await fetch_finalized_state("http://example.com", State) assert str(exception_info.value) == ( - "Corrupt checkpoint state payload: Slot: expected 8 bytes, got 2" + "Corrupt checkpoint state payload: slot: Slot needs 8 bytes, the input holds 2" ) async def test_trailing_slash_stripped_from_url(self) -> None: @@ -272,7 +272,7 @@ async def test_corrupt_ssz_raises_checkpoint_sync_error(self) -> None: await fetch_finalized_block("http://example.com") assert ( str(exception_info.value) == "Failed to fetch signed block: " - "SignedBlock: first offset 1663106815 != fixed-part end 8" + "the first offset is 1663106815, and the fixed part ends at 8" ) diff --git a/tests/node/sync/test_head_sync.py b/tests/node/sync/test_head_sync.py index e3de1f049..4205948d9 100644 --- a/tests/node/sync/test_head_sync.py +++ b/tests/node/sync/test_head_sync.py @@ -6,17 +6,17 @@ from typing import Any, cast import pytest +from ssz import Uint64, hash_tree_root from consensus_testing import MockForkchoiceStore, make_signed_block from lean_spec.node.networking import PeerId from lean_spec.node.sync.backfill_sync import BackfillSync from lean_spec.node.sync.block_cache import BlockCache from lean_spec.node.sync.head_sync import HeadSync -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Checkpoint, Slot, ValidatorIndex from lean_spec.spec.forks.lstar import Store from lean_spec.spec.forks.lstar.containers import SignedBlock -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 @dataclass diff --git a/tests/node/sync/test_peer_manager.py b/tests/node/sync/test_peer_manager.py index 97e3f65e3..da980eea9 100644 --- a/tests/node/sync/test_peer_manager.py +++ b/tests/node/sync/test_peer_manager.py @@ -17,7 +17,7 @@ SyncPeer, ) from lean_spec.spec.forks import Checkpoint, Slot -from lean_spec.spec.ssz import Bytes32 +from lean_spec.spec.ssz_types import Bytes32 def peer(name: str) -> PeerId: diff --git a/tests/node/sync/test_service.py b/tests/node/sync/test_service.py index 5777cee54..bf1f1b207 100644 --- a/tests/node/sync/test_service.py +++ b/tests/node/sync/test_service.py @@ -6,6 +6,7 @@ from typing import cast import pytest +from ssz import Uint64, hash_tree_root from consensus_testing import ( MockForkchoiceStore, @@ -26,7 +27,6 @@ from lean_spec.node.sync.config import MAX_PENDING_ATTESTATIONS from lean_spec.node.sync.service import SyncService from lean_spec.node.sync.states import SyncState -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.crypto.xmss.containers import PublicKey from lean_spec.spec.forks import ( Checkpoint, @@ -46,7 +46,7 @@ SingleMessageAggregate, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 def make_store_with_attestation_data( diff --git a/tests/node/test_anchor.py b/tests/node/test_anchor.py index b091fb8ec..f64817111 100644 --- a/tests/node/test_anchor.py +++ b/tests/node/test_anchor.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest +from ssz import Uint64, hash_tree_root from consensus_testing import ( build_genesis_state, @@ -14,11 +15,10 @@ from lean_spec.node.anchor import Anchor from lean_spec.node.genesis import GenesisConfig from lean_spec.node.sync.checkpoint_sync import CheckpointSyncError -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import SignedBlock, Slot from lean_spec.spec.forks.lstar import State from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 def _signed_genesis_block(state: State) -> SignedBlock: diff --git a/tests/node/test_genesis.py b/tests/node/test_genesis.py index bcacf238e..6e3c3698f 100644 --- a/tests/node/test_genesis.py +++ b/tests/node/test_genesis.py @@ -7,10 +7,11 @@ import pytest import yaml from pydantic import ValidationError +from ssz import Uint64 from lean_spec.node.genesis import GenesisConfig from lean_spec.spec.forks import VALIDATOR_REGISTRY_LIMIT, ValidatorIndex -from lean_spec.spec.ssz import Bytes52, SSZValueError, Uint64 +from lean_spec.spec.ssz_types import Bytes52 def _load(content: str) -> GenesisConfig: @@ -170,7 +171,7 @@ def test_wrong_length_public_key_raises_error(self) -> None: ], } ) - with pytest.raises(SSZValueError): + with pytest.raises(ValidationError): _load(yaml_content) def test_missing_genesis_time_raises_error(self) -> None: diff --git a/tests/node/test_node.py b/tests/node/test_node.py index 881065951..4c3d22e9a 100644 --- a/tests/node/test_node.py +++ b/tests/node/test_node.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest +from ssz import Uint64, hash_tree_root from consensus_testing import ( MockEventSource, @@ -20,7 +21,6 @@ from lean_spec.node.storage.sqlite import SQLiteDatabase from lean_spec.node.validator import ValidatorRegistry from lean_spec.node.validator.registry import ValidatorEntry -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.forks import Checkpoint, Interval, Slot, ValidatorIndex from lean_spec.spec.forks.lstar import State from lean_spec.spec.forks.lstar.config import ( @@ -45,7 +45,7 @@ Validators, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import ByteList512KiB, Bytes32, Bytes52, Uint64 +from lean_spec.spec.ssz_types import ByteList512KiB, Bytes32, Bytes52 GENESIS_TIME = Uint64(1704067200) diff --git a/tests/node/validator/test_registry.py b/tests/node/validator/test_registry.py index 5ac8e77b2..8013dfd94 100644 --- a/tests/node/validator/test_registry.py +++ b/tests/node/validator/test_registry.py @@ -7,6 +7,7 @@ import pytest import yaml from pydantic import ValidationError +from ssz import SSZValueError from consensus_testing.keys import XmssKeyManager from lean_spec.node.validator import ValidatorRegistry @@ -16,8 +17,7 @@ ValidatorManifestEntry, ) from lean_spec.spec.forks import Slot, ValidatorIndex -from lean_spec.spec.ssz import Bytes52 -from lean_spec.spec.ssz.exceptions import SSZValueError +from lean_spec.spec.ssz_types import Bytes52 def registry_state(registry: ValidatorRegistry) -> dict[ValidatorIndex, tuple[object, object]]: @@ -483,7 +483,8 @@ def test_corrupt_attestation_key_file_raises(self, tmp_path: Path) -> None: manifest_path=manifest_file, ) assert str(exception_info.value) == ( - "Failed to load attestation key for validator 0: PRFKey: expected 32 bytes, got 13" + "Failed to load attestation key for validator 0: " + "prf_key: PRFKey needs 32 bytes, the input holds 13" ) def test_corrupt_proposal_key_file_raises(self, tmp_path: Path, km: XmssKeyManager) -> None: @@ -506,7 +507,8 @@ def test_corrupt_proposal_key_file_raises(self, tmp_path: Path, km: XmssKeyManag manifest_path=manifest_file, ) assert str(exception_info.value) == ( - "Failed to load proposal key for validator 0: PRFKey: expected 32 bytes, got 13" + "Failed to load proposal key for validator 0: " + "prf_key: PRFKey needs 32 bytes, the input holds 13" ) def test_same_key_for_both_roles_raises(self, tmp_path: Path) -> None: diff --git a/tests/node/validator/test_service.py b/tests/node/validator/test_service.py index 6eb3cd197..3bbdd2070 100644 --- a/tests/node/validator/test_service.py +++ b/tests/node/validator/test_service.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest +from ssz import Uint64, hash_tree_root from consensus_testing import ( TEST_VALIDATOR_INDEX, @@ -20,7 +21,6 @@ from lean_spec.node.validator import ValidatorRegistry, ValidatorService from lean_spec.node.validator.constants import SYNC_LAG_THRESHOLD from lean_spec.node.validator.registry import ValidatorEntry -from lean_spec.spec.crypto.merkleization import hash_tree_root from lean_spec.spec.crypto.xmss import TARGET_SIGNATURE_SCHEME from lean_spec.spec.forks import RejectionReason, Slot, SpecRejectionError, ValidatorIndex from lean_spec.spec.forks.lstar import Store @@ -34,7 +34,7 @@ SingleMessageAggregate, ) from lean_spec.spec.forks.lstar.spec import LstarSpec -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 # Patch target for the XMSS scheme reference inside service.py. _SCHEME = "lean_spec.node.validator.service.TARGET_SIGNATURE_SCHEME" diff --git a/tests/spec/crypto/test_koalabear.py b/tests/spec/crypto/test_koalabear.py index cbb56b779..b6a2d67c4 100644 --- a/tests/spec/crypto/test_koalabear.py +++ b/tests/spec/crypto/test_koalabear.py @@ -9,13 +9,10 @@ import pytest from pydantic import BaseModel, ValidationError +from ssz import SSZTypeError, SSZValueError, hash_tree_root from lean_spec.spec.crypto.koalabear import Fp, P -from lean_spec.spec.ssz.exceptions import ( - SSZSerializationError, - SSZTypeError, - SSZValueError, -) +from lean_spec.spec.ssz_types import Bytes32, Vector def test_constants() -> None: @@ -83,17 +80,17 @@ def test_ssz_deserialize_wrong_scope() -> None: """Test deserialize error when scope doesn't match P_BYTES.""" encoded_bytes = b"\x2a\x00\x00\x00" stream = io.BytesIO(encoded_bytes) - with pytest.raises(SSZSerializationError) as exception_info: + with pytest.raises(SSZValueError) as exception_info: Fp.deserialize(stream, 3) - assert str(exception_info.value) == "Expected 4 bytes for Fp, got 3" + assert str(exception_info.value) == "Fp spans 4 bytes, and the budget is 3" def test_ssz_deserialize_short_data() -> None: """Test deserialize error when stream has insufficient data.""" stream = io.BytesIO(b"\x01\x02\x03") # Only 3 bytes - with pytest.raises(SSZSerializationError) as exception_info: + with pytest.raises(SSZValueError) as exception_info: Fp.deserialize(stream, 4) - assert str(exception_info.value) == "Expected 4 bytes for Fp, got 3" + assert str(exception_info.value) == "Fp needs 4 bytes, the input holds 3" def test_ssz_deserialize_exceeds_modulus() -> None: @@ -104,7 +101,7 @@ def test_ssz_deserialize_exceeds_modulus() -> None: stream = io.BytesIO(invalid_data) with pytest.raises(SSZValueError) as exception_info: Fp.deserialize(stream, 4) - assert str(exception_info.value) == f"Value {P} exceeds field modulus {P}" + assert str(exception_info.value) == f"{P} is out of range for Fp [0, {P - 1}]" def test_ssz_encode_decode_bytes() -> None: @@ -169,7 +166,7 @@ def test_new_rejects_non_int_inputs(bad_value: Any, type_name: str) -> None: """Constructing Fp with a non-int input raises SSZTypeError naming the offending type.""" with pytest.raises(SSZTypeError) as exception_info: Fp(bad_value) - assert str(exception_info.value) == f"Field value must be an integer, got {type_name}" + assert str(exception_info.value) == f"expected int, got {type_name}" @pytest.mark.parametrize( @@ -320,9 +317,9 @@ def test_encode_decode_bytes_round_trip_at_p_minus_one() -> None: def test_decode_bytes_rejects_oversized_input() -> None: """A five-byte buffer is rejected because the scope guard fires before any read.""" - with pytest.raises(SSZSerializationError) as exception_info: + with pytest.raises(SSZValueError) as exception_info: Fp.decode_bytes(b"\x00\x00\x00\x00\x01") - assert str(exception_info.value) == "Expected 4 bytes for Fp, got 5" + assert str(exception_info.value) == "Fp needs 4 bytes, the input holds 5" class _PydanticModelWithFp(BaseModel): @@ -358,3 +355,29 @@ def test_pydantic_json_serialization_drops_subtype_to_plain_int() -> None: serialized = model.model_dump(mode="json") assert serialized == {"x": 99} assert json.loads(model.model_dump_json()) == {"x": 99} + + +@pytest.mark.parametrize( + "integer_value", + [ + 0, + 1, + 42, + (1 << 31) - 2**24, # Largest residue under the KoalaBear modulus. + ], +) +def test_hash_tree_root_fp(integer_value: int) -> None: + """KoalaBear field elements hash as their four-byte little-endian encoding.""" + padded_encoding = integer_value.to_bytes(4, "little").ljust(32, b"\x00") + assert hash_tree_root(Fp(integer_value)) == Bytes32(padded_encoding) + + +def test_hash_tree_root_packs_a_vector_of_field_elements() -> None: + """Riding on the 32-bit unsigned integer packs eight elements to a leaf, not one each.""" + + class EightElements(Vector[Fp]): + LENGTH = 8 + + elements = [Fp(index) for index in range(8)] + packed_chunk = b"".join(index.to_bytes(4, "little") for index in range(8)) + assert hash_tree_root(EightElements(data=elements)) == Bytes32(packed_chunk) diff --git a/tests/spec/crypto/test_merkleization.py b/tests/spec/crypto/test_merkleization.py deleted file mode 100644 index bb85b70b1..000000000 --- a/tests/spec/crypto/test_merkleization.py +++ /dev/null @@ -1,810 +0,0 @@ -"""Unit tests for SSZ Merkleization primitives and the hash_tree_root dispatch.""" - -from __future__ import annotations - -from collections.abc import Iterable -from hashlib import sha256 - -import pytest - -from lean_spec.spec.crypto.koalabear import Fp -from lean_spec.spec.crypto.merkleization import ( - _next_pow2, - _zero_tree_root, - hash_tree_root, - merkleize, - mix_in_length, -) -from lean_spec.spec.ssz import ( - ZERO_HASH, - BaseByteList, - BaseBytes, - Bytes32, - Uint8, - Uint16, - Uint32, - Uint64, -) -from lean_spec.spec.ssz.bitfields import BaseBitlist, BaseBitvector -from lean_spec.spec.ssz.boolean import Boolean -from lean_spec.spec.ssz.collections import SSZList, SSZVector -from lean_spec.spec.ssz.container import Container - - -def h(a: bytes, b: bytes) -> Bytes32: - """Pairwise SHA-256 of two 32-byte nodes; used to build expected roots.""" - return Bytes32(sha256(a + b).digest()) - - -def pad(payload: bytes) -> Bytes32: - """Right-pad a payload to 32 bytes.""" - return Bytes32(payload.ljust(32, b"\x00")) - - -def merge(leaf: Bytes32, branch: Iterable[Bytes32]) -> Bytes32: - """Walk a single leaf up a chain of right siblings, hashing left at each step.""" - running_node = leaf - for sibling in branch: - running_node = h(running_node, sibling) - return running_node - - -# Sample chunks for testing, sample_chunks[i] = bytes32(i) -sample_chunks = [Bytes32(i.to_bytes(32, "little")) for i in range(16)] - -# Pre-calculate zero-tree roots for assertions -# Z[0] = ZERO_HASH, Z[1] = h(Z[0], Z[0]), Z[2] = h(Z[1], Z[1]), etc. -Z = [ZERO_HASH] -for _ in range(20): - Z.append(h(Z[-1], Z[-1])) - - -@pytest.mark.parametrize( - "x, expected", - [ - (0, 1), # Edge case: 0 should result in 1 - (1, 1), # A power of two - (2, 2), # A power of two - (3, 4), # A number between powers of two - (4, 4), # A power of two - (5, 8), - (7, 8), - (8, 8), - (9, 16), - (1023, 1024), - (1024, 1024), # A larger power of two - ], -) -def test_next_pow2(x: int, expected: int) -> None: - """Returns the smallest power of two at or above the input, with 1 for 0 and 1.""" - assert _next_pow2(x) == expected - - -def test_merkleize_empty_no_limit() -> None: - """Merkleizing an empty list with no limit returns the all-zero leaf.""" - assert merkleize([]) == ZERO_HASH - - -@pytest.mark.parametrize( - "limit, expected_zero_root", - [ - (0, Z[0]), # limit=0 -> width=1 -> root is Z[0] - (1, Z[0]), # limit=1 -> width=1 -> root is Z[0] - (2, Z[1]), # limit=2 -> width=2 -> root is Z[1] - (3, Z[2]), # limit=3 -> width=4 -> root is Z[2] - (7, Z[3]), # limit=7 -> width=8 -> root is Z[3] - (8, Z[3]), - ], -) -def test_merkleize_empty_with_limit(limit: int, expected_zero_root: Bytes32) -> None: - """Empty input with a limit yields the zero-subtree root at the rounded-up width.""" - assert merkleize([], limit=limit) == expected_zero_root - - -def test_merkleize_single_chunk() -> None: - """The root of a single chunk is the chunk itself.""" - assert merkleize([sample_chunks[1]]) == sample_chunks[1] - - -def test_merkleize_power_of_two_chunks() -> None: - """A power-of-two leaf count needs no padding.""" - # Test with 2 chunks - assert merkleize([sample_chunks[0], sample_chunks[1]]) == h(sample_chunks[0], sample_chunks[1]) - # Test with 4 chunks - root_4 = h(h(sample_chunks[0], sample_chunks[1]), h(sample_chunks[2], sample_chunks[3])) - assert merkleize(sample_chunks[0:4]) == root_4 - - -def test_merkleize_non_power_of_two_chunks() -> None: - """A non-power-of-two leaf count pads to the next power of two.""" - # Test with 3 chunks (pads to 4) - expected_root_3_chunks = h(h(sample_chunks[0], sample_chunks[1]), h(sample_chunks[2], Z[0])) - assert merkleize(sample_chunks[0:3]) == expected_root_3_chunks - # Test with 5 chunks (pads to 8) - h01 = h(sample_chunks[0], sample_chunks[1]) - h23 = h(sample_chunks[2], sample_chunks[3]) - h4z = h(sample_chunks[4], Z[0]) - # The remaining leaves are zero, so their parent is h(Z[0], Z[0]) = Z[1] - expected_root_5_chunks = h(h(h01, h23), h(h4z, Z[1])) - assert merkleize(sample_chunks[0:5]) == expected_root_5_chunks - - -def test_merkleize_with_limit_padding() -> None: - """A limit larger than the leaf count widens the tree to the next power of two of the limit.""" - # 3 chunks, but limit is 8 (pads to width 8) - h01 = h(sample_chunks[0], sample_chunks[1]) - h2z = h(sample_chunks[2], Z[0]) - # The parent of h01 and h2z - left_branch = h(h01, h2z) - # The right branch is a zero-tree of width 4, so its root is Z[2]. - right_branch = Z[2] - expected_root = h(left_branch, right_branch) - assert merkleize(sample_chunks[0:3], limit=8) == expected_root - - -def test_merkleize_error_on_exceeding_limit() -> None: - """Raises when the chunk count exceeds the limit.""" - with pytest.raises(ValueError) as exception_info: - merkleize(sample_chunks[0:5], limit=4) - assert str(exception_info.value) == "merkleize: input exceeds limit" - - -def test_mix_in_length() -> None: - """Mixes the length encoded as little-endian uint256 into the root.""" - root = sample_chunks[0] - length = 12345 - length_bytes = Bytes32(length.to_bytes(32, "little")) - expected_root = h(root, length_bytes) - assert mix_in_length(root, length) == expected_root - - -def test_mix_in_length_zero() -> None: - """Zero is a valid length.""" - root = sample_chunks[0] - length = 0 - length_bytes = Bytes32(length.to_bytes(32, "little")) - expected_root = h(root, length_bytes) - assert mix_in_length(root, length) == expected_root - - -def test_mix_in_length_error_on_negative() -> None: - """Rejects negative lengths.""" - with pytest.raises(ValueError): - mix_in_length(sample_chunks[0], -1) - - -def test_zero_tree_root_internal() -> None: - """Returns the cached zero-subtree root at depths within the cache.""" - assert _zero_tree_root(1) == Z[0] - assert _zero_tree_root(2) == Z[1] - assert _zero_tree_root(4) == Z[2] - assert _zero_tree_root(8) == Z[3] - assert _zero_tree_root(16) == Z[4] - - -class Bytes48(BaseBytes): - """Test-local fixed-size byte array of 48 bytes.""" - - LENGTH = 48 - - -class Bytes96(BaseBytes): - """Test-local fixed-size byte array of 96 bytes spanning three chunks.""" - - LENGTH = 96 - - -class ByteList7(BaseByteList): - """Byte list with a single-chunk capacity of 7 bytes.""" - - LIMIT = 7 - - -class ByteList10(BaseByteList): - """Byte list with a single-chunk capacity of 10 bytes.""" - - LIMIT = 10 - - -class ByteList32(BaseByteList): - """Byte list whose capacity exactly fills one chunk.""" - - LIMIT = 32 - - -class ByteList50(BaseByteList): - """Byte list spanning two chunks of capacity.""" - - LIMIT = 50 - - -class ByteList256(BaseByteList): - """Byte list with capacity for eight chunks.""" - - LIMIT = 256 - - -class ByteList2048(BaseByteList): - """Byte list with capacity for sixty-four chunks.""" - - LIMIT = 2048 - - -class Bitvector1(BaseBitvector): - """Single-bit bitvector.""" - - LENGTH = 1 - - -class Bitvector3(BaseBitvector): - """Three-bit bitvector inside one byte.""" - - LENGTH = 3 - - -class Bitvector8(BaseBitvector): - """Bitvector aligned to one byte.""" - - LENGTH = 8 - - -class Bitvector9(BaseBitvector): - """Bitvector spilling into a second byte.""" - - LENGTH = 9 - - -class Bitvector256(BaseBitvector): - """Bitvector whose data fills exactly one 32-byte chunk.""" - - LENGTH = 256 - - -class Bitvector512(BaseBitvector): - """Bitvector whose data fills exactly two chunks.""" - - LENGTH = 512 - - -class Bitlist3(BaseBitlist): - """Bitlist limit of three bits.""" - - LIMIT = 3 - - -class Bitlist8(BaseBitlist): - """Bitlist limit of eight bits.""" - - LIMIT = 8 - - -class Bitlist256(BaseBitlist): - """Bitlist whose data root fits one chunk.""" - - LIMIT = 256 - - -class Bitlist512(BaseBitlist): - """Bitlist whose data root spans two chunks.""" - - LIMIT = 512 - - -class Uint16Vector1(SSZVector[Uint16]): - """Single-element vector of Uint16.""" - - LENGTH = 1 - - -class Uint16Vector2(SSZVector[Uint16]): - """Two-element vector of Uint16.""" - - LENGTH = 2 - - -class Uint16Vector16(SSZVector[Uint16]): - """Sixteen-element vector of Uint16 filling exactly one chunk.""" - - LENGTH = 16 - - -class Bytes32Vector3(SSZVector[Bytes32]): - """Vector of three composite Bytes32 elements.""" - - LENGTH = 3 - - -class Uint16List32(SSZList[Uint16]): - """List of Uint16 with a 32-element limit.""" - - LIMIT = 32 - - -class Uint16List1024(SSZList[Uint16]): - """List of Uint16 with a 1024-element limit used as a container field.""" - - LIMIT = 1024 - - -class Uint32List128(SSZList[Uint32]): - """List of Uint32 with a 128-element limit.""" - - LIMIT = 128 - - -class Bytes32List32(SSZList[Bytes32]): - """List of composite Bytes32 elements with a 32-element limit.""" - - LIMIT = 32 - - -class SingleField(Container): - """Container holding a single basic field.""" - - A: Uint8 - - -class Small(Container): - """Container with two byte-aligned fields fitting in one chunk each.""" - - A: Uint16 - B: Uint16 - - -class Fixed(Container): - """Container with three fixed-size fields needing tree padding.""" - - A: Uint8 - B: Uint64 - C: Uint32 - - -class Var(Container): - """Container with a variable-size middle field.""" - - A: Uint16 - B: Uint16List1024 - C: Uint8 - - -class FixedVector4(SSZVector[Fixed]): - """Vector of four fixed-size containers.""" - - LENGTH = 4 - - -class VarVector2(SSZVector[Var]): - """Vector of two variable-size containers.""" - - LENGTH = 2 - - -class EmptyContainer(Container): - """Container with zero fields.""" - - -def le_padded(integer_value: int, byte_length: int) -> Bytes32: - """Encode an integer little-endian and right-pad to one chunk.""" - return pad(integer_value.to_bytes(byte_length, "little")) - - -@pytest.mark.parametrize( - "uint_type, byte_length, integer_value", - [ - (Uint8, 1, 0x00), - (Uint8, 1, 0x01), - (Uint8, 1, 0xAB), - (Uint8, 1, 0xFF), - (Uint16, 2, 0x0000), - (Uint16, 2, 0xABCD), - (Uint16, 2, 0xFFFF), - (Uint32, 4, 0x00000000), - (Uint32, 4, 0x01234567), - (Uint32, 4, 0xFFFFFFFF), - (Uint64, 8, 0x0000000000000000), - (Uint64, 8, 0x0123456789ABCDEF), - (Uint64, 8, 0xFFFFFFFFFFFFFFFF), - ], -) -def test_hash_tree_root_uints(uint_type: type, byte_length: int, integer_value: int) -> None: - """Unsigned integers hash as their little-endian bytes padded to one chunk.""" - assert hash_tree_root(uint_type(integer_value)) == le_padded(integer_value, byte_length) - - -@pytest.mark.parametrize( - "boolean, expected_byte", - [ - (Boolean(False), b"\x00"), - (Boolean(True), b"\x01"), - ], -) -def test_hash_tree_root_boolean(boolean: Boolean, expected_byte: bytes) -> None: - """Boolean hashes to a single byte padded to one chunk.""" - assert hash_tree_root(boolean) == pad(expected_byte) - - -@pytest.mark.parametrize( - "integer_value", - [ - 0, - 1, - 42, - (1 << 31) - 2**24, # Largest residue under the KoalaBear modulus. - ], -) -def test_hash_tree_root_fp(integer_value: int) -> None: - """KoalaBear field elements hash as their four-byte little-endian encoding.""" - assert hash_tree_root(Fp(integer_value)) == le_padded(integer_value, 4) - - -@pytest.mark.parametrize( - "payload, expected_root", - [ - # Empty: zero chunks merkleizes to the all-zero leaf. - (b"", Z[0]), - # One byte fits in one chunk and is its own root. - (b"\xab", pad(b"\xab")), - # 31 bytes still hash to a single padded chunk. - (b"\xff" * 31, pad(b"\xff" * 31)), - # 32 bytes are exactly one chunk and are their own root. - (b"\xff" * 32, Bytes32(b"\xff" * 32)), - # 33 bytes form two chunks; the second is padded. - (b"\xff" * 32 + b"\x01", h(b"\xff" * 32, pad(b"\x01"))), - # 64 bytes form two full chunks hashed together. - (b"\xaa" * 32 + b"\xbb" * 32, h(b"\xaa" * 32, b"\xbb" * 32)), - ], -) -def test_hash_tree_root_bytes_known_vectors(payload: bytes, expected_root: Bytes32) -> None: - """Raw byte payloads hash to the merkle root of their packed chunks.""" - assert hash_tree_root(payload) == expected_root - - -def test_hash_tree_root_bytevector_single_chunk() -> None: - """A 32-byte vector is exactly one chunk and is its own root.""" - raw_bytes = bytes(range(32)) - assert hash_tree_root(Bytes32(raw_bytes)) == Bytes32(raw_bytes) - - -def test_hash_tree_root_bytevector_two_chunks() -> None: - """A 48-byte vector hashes its two chunks together; the trailing chunk is padded.""" - raw_bytes = bytes(range(48)) - expected_root = h(raw_bytes[:32], pad(raw_bytes[32:])) - assert hash_tree_root(Bytes48(raw_bytes)) == expected_root - - -def test_hash_tree_root_bytevector_three_chunks() -> None: - """A 96-byte vector merkleizes its three chunks with a zero pad to width four.""" - raw_bytes = bytes(range(96)) - left = h(raw_bytes[0:32], raw_bytes[32:64]) - right = h(raw_bytes[64:96], Z[0]) - assert hash_tree_root(Bytes96(raw_bytes)) == h(left, right) - - -def test_hash_tree_root_bytelist_empty_single_chunk_capacity() -> None: - """An empty list with single-chunk capacity mixes a zero chunk with length zero.""" - expected_root = h(Z[0], pad(b"\x00")) - assert hash_tree_root(ByteList10(data=b"")) == expected_root - - -def test_hash_tree_root_bytelist_empty_large_capacity() -> None: - """An empty list with 64-chunk capacity uses the depth-6 zero root before mix-in.""" - expected_root = h(Z[6], pad(b"\x00")) - assert hash_tree_root(ByteList2048(data=b"")) == expected_root - - -@pytest.mark.parametrize( - "list_cls, payload, expected_root", - [ - # Small list fits in one chunk; data root is the padded payload. - ( - ByteList7, - b"\x00\x01\x02\x03\x04\x05\x06", - h(pad(b"\x00\x01\x02\x03\x04\x05\x06"), pad(b"\x07")), - ), - # Two-chunk capacity holds a 50-byte payload that spans both chunks. - ( - ByteList50, - bytes(range(50)), - h( - h(bytes(range(32)), pad(bytes(range(32, 50)))), - pad(b"\x32"), - ), - ), - # Eight-chunk capacity with six bytes pads the lone data chunk to depth three. - ( - ByteList256, - b"\x00\x01\x02\x03\x04\x05", - h( - merge(pad(b"\x00\x01\x02\x03\x04\x05"), [Z[0], Z[1], Z[2]]), - pad(b"\x06"), - ), - ), - # Capacity boundary: a full single chunk of data uses the chunk as the data root. - ( - ByteList32, - bytes(range(32)), - h(Bytes32(bytes(range(32))), pad(b"\x20")), - ), - ], -) -def test_hash_tree_root_bytelist_various( - list_cls: type[BaseByteList], payload: bytes, expected_root: Bytes32 -) -> None: - """Variable-length byte lists merkleize their packed data then mix in the length.""" - assert hash_tree_root(list_cls(data=payload)) == expected_root - - -def _bools(*values: int) -> list[Boolean]: - """Build a typed boolean sequence from 0/1 integers.""" - return [Boolean(bool(bit)) for bit in values] - - -@pytest.mark.parametrize( - "bv_cls, bits, expected_payload", - [ - # Single bit set produces 0x01 padded. - (Bitvector1, _bools(1), b"\x01"), - # Three bits 0,1,0 produce 0b010 = 0x02 padded. - (Bitvector3, _bools(0, 1, 0), b"\x02"), - # Eight ones fill one byte at 0xff. - (Bitvector8, _bools(*([1] * 8)), b"\xff"), - # Nine ones spill into a second byte at 0x01. - (Bitvector9, _bools(*([1] * 9)), b"\xff\x01"), - ], -) -def test_hash_tree_root_bitvector_single_chunk( - bv_cls: type[BaseBitvector], - bits: list[Boolean], - expected_payload: bytes, -) -> None: - """Small bitvectors merkleize to a single padded chunk of their packed bytes.""" - bv = bv_cls(data=bits) - assert bv.encode_bytes() == expected_payload - assert hash_tree_root(bv) == pad(expected_payload) - - -def test_hash_tree_root_bitvector_one_chunk_boundary() -> None: - """A 256-bit vector of ones packs into exactly one all-ones chunk.""" - bv = Bitvector256(data=_bools(*([1] * 256))) - assert hash_tree_root(bv) == Bytes32(b"\xff" * 32) - - -def test_hash_tree_root_bitvector_two_chunks() -> None: - """A 512-bit vector of ones hashes two all-ones chunks together.""" - bv = Bitvector512(data=_bools(*([1] * 512))) - assert hash_tree_root(bv) == h(b"\xff" * 32, b"\xff" * 32) - - -@pytest.mark.parametrize( - "bl_cls, bits, expected_data_root, expected_length", - [ - # Bitlist[3] with 0,1,0 has data byte 0x02 and length 3. - (Bitlist3, _bools(0, 1, 0), pad(b"\x02"), 3), - # Bitlist[8] with all ones has data byte 0xff and length 8. - (Bitlist8, _bools(*([1] * 8)), pad(b"\xff"), 8), - # Bitlist[8] empty: data root is the zero chunk and length is 0. - (Bitlist8, _bools(), Z[0], 0), - ], -) -def test_hash_tree_root_bitlist_small( - bl_cls: type[BaseBitlist], - bits: list[Boolean], - expected_data_root: Bytes32, - expected_length: int, -) -> None: - """Short bitlists hash the data chunk and mix in the bit count.""" - bl = bl_cls(data=bits) - expected_root = h(expected_data_root, pad(expected_length.to_bytes(32, "little"))) - assert hash_tree_root(bl) == expected_root - - -def test_hash_tree_root_bitlist_chunk_boundary() -> None: - """A bitlist whose data fills exactly one chunk mixes its 256-bit length in.""" - bl = Bitlist256(data=_bools(*([1] * 256))) - expected_root = h(b"\xff" * 32, pad((256).to_bytes(32, "little"))) - assert hash_tree_root(bl) == expected_root - - -def test_hash_tree_root_bitlist_two_chunks() -> None: - """A bitlist whose data spans two chunks merkleizes them and mixes in 512.""" - bl = Bitlist512(data=_bools(*([1] * 512))) - base = h(b"\xff" * 32, b"\xff" * 32) - expected_root = h(base, pad((512).to_bytes(32, "little"))) - assert hash_tree_root(bl) == expected_root - - -def test_hash_tree_root_vector_basic_single_chunk() -> None: - """A vector of two Uint16 fits in one chunk; the root is the padded payload.""" - vector = Uint16Vector2(data=[Uint16(0x4567), Uint16(0x0123)]) - assert hash_tree_root(vector) == pad(b"\x67\x45\x23\x01") - - -def test_hash_tree_root_vector_basic_chunk_boundary() -> None: - """A vector of sixteen Uint16 fills exactly one 32-byte chunk.""" - vector = Uint16Vector16(data=[Uint16(i) for i in range(16)]) - packed_bytes = b"".join(i.to_bytes(2, "little") for i in range(16)) - assert hash_tree_root(vector) == Bytes32(packed_bytes) - - -def test_hash_tree_root_vector_single_element() -> None: - """A one-element vector of Uint16 yields the padded little-endian element.""" - vector = Uint16Vector1(data=[Uint16(0xABCD)]) - assert hash_tree_root(vector) == pad(b"\xcd\xab") - - -def test_hash_tree_root_vector_composite_elements() -> None: - """A vector of three Bytes32 leaves merkleizes its element roots padded to width four.""" - leaf_a = Bytes32(b"\xbb\xaa" + b"\x00" * 30) - leaf_b = Bytes32(b"\xad\xc0" + b"\x00" * 30) - leaf_c = Bytes32(b"\xff\xee" + b"\x00" * 30) - vector = Bytes32Vector3(data=[leaf_a, leaf_b, leaf_c]) - assert hash_tree_root(vector) == h(h(leaf_a, leaf_b), h(leaf_c, Z[0])) - - -def test_hash_tree_root_list_basic_small_limit() -> None: - """A list of three Uint16 with capacity for 32 elements packs into a two-chunk tree.""" - test_list = Uint16List32(data=[Uint16(0xAABB), Uint16(0xC0AD), Uint16(0xEEFF)]) - base = h(pad(b"\xbb\xaa\xad\xc0\xff\xee"), Z[0]) - expected_root = h(base, pad(b"\x03")) - assert hash_tree_root(test_list) == expected_root - - -def test_hash_tree_root_list_basic_large_limit() -> None: - """A list of three Uint32 with capacity 128 pads up four levels then mixes in the length.""" - test_list = Uint32List128(data=[Uint32(0xAABB), Uint32(0xC0AD), Uint32(0xEEFF)]) - base = merge(pad(b"\xbb\xaa\x00\x00\xad\xc0\x00\x00\xff\xee\x00\x00"), Z[0:4]) - expected_root = h(base, pad(b"\x03")) - assert hash_tree_root(test_list) == expected_root - - -def test_hash_tree_root_list_basic_empty() -> None: - """An empty list with a large capacity uses the all-zero subtree at the capacity depth.""" - test_list = Uint32List128(data=[]) - expected_root = h(Z[4], pad(b"\x00")) - assert hash_tree_root(test_list) == expected_root - - -def test_hash_tree_root_list_composite_elements() -> None: - """A list of three Bytes32 elements merkleizes leaves to capacity depth then mixes length.""" - leaf_a = Bytes32(b"\xbb\xaa" + b"\x00" * 30) - leaf_b = Bytes32(b"\xad\xc0" + b"\x00" * 30) - leaf_c = Bytes32(b"\xff\xee" + b"\x00" * 30) - test_list = Bytes32List32(data=[leaf_a, leaf_b, leaf_c]) - base = h(h(leaf_a, leaf_b), h(leaf_c, Z[0])) - merkle = merge(base, Z[2:5]) - expected_root = h(merkle, pad(b"\x03")) - assert hash_tree_root(test_list) == expected_root - - -def test_hash_tree_root_container_empty() -> None: - """A container with no fields hashes to the empty-input merkle root.""" - assert hash_tree_root(EmptyContainer()) == Z[0] - - -def test_hash_tree_root_container_single_field() -> None: - """A container with one basic field hashes that field as its only leaf.""" - container = SingleField(A=Uint8(0xAB)) - assert hash_tree_root(container) == pad(b"\xab") - - -def test_hash_tree_root_container_two_fields() -> None: - """A container with two basic fields hashes each as its own leaf.""" - container = Small(A=Uint16(0x4567), B=Uint16(0x0123)) - assert hash_tree_root(container) == h(pad(b"\x67\x45"), pad(b"\x23\x01")) - - -def test_hash_tree_root_container_three_fields_pads_to_four() -> None: - """A three-field container pads its leaves with one zero chunk to width four.""" - container = Fixed(A=Uint8(0xAB), B=Uint64(0xAABBCCDDEEFF0011), C=Uint32(0x12345678)) - left = h(pad(b"\xab"), pad(b"\x11\x00\xff\xee\xdd\xcc\xbb\xaa")) - right = h(pad(b"\x78\x56\x34\x12"), Z[0]) - assert hash_tree_root(container) == h(left, right) - - -def test_hash_tree_root_container_with_empty_list_field() -> None: - """An empty variable-size field contributes its own zero-tree root with length zero.""" - container = Var(A=Uint16(0xABCD), B=Uint16List1024(data=()), C=Uint8(0xFF)) - expected_b = h(Z[6], pad(b"\x00")) - left = h(pad(b"\xcd\xab"), expected_b) - right = h(pad(b"\xff"), Z[0]) - assert hash_tree_root(container) == h(left, right) - - -def test_hash_tree_root_container_with_populated_list_field() -> None: - """A populated variable-size field contributes its data root with the element count.""" - container = Var( - A=Uint16(0xABCD), - B=Uint16List1024(data=(Uint16(1), Uint16(2), Uint16(3))), - C=Uint8(0xFF), - ) - base = merge(pad(b"\x01\x00\x02\x00\x03\x00"), Z[0:6]) - expected_b = h(base, pad(b"\x03")) - left = h(pad(b"\xcd\xab"), expected_b) - right = h(pad(b"\xff"), Z[0]) - assert hash_tree_root(container) == h(left, right) - - -def test_hash_tree_root_vector_of_composite_containers() -> None: - """A fixed-length vector of containers hashes the per-element roots into a balanced tree.""" - - def fixed_root(a: bytes, b: bytes, c: bytes) -> Bytes32: - return h(h(pad(a), pad(b)), h(pad(c), Z[0])) - - fixed_vector = FixedVector4( - data=[ - Fixed(A=Uint8(0xCC), B=Uint64(0x4242424242424242), C=Uint32(0x13371337)), - Fixed(A=Uint8(0xDD), B=Uint64(0x3333333333333333), C=Uint32(0xABCDABCD)), - Fixed(A=Uint8(0xEE), B=Uint64(0x4444444444444444), C=Uint32(0x00112233)), - Fixed(A=Uint8(0xFF), B=Uint64(0x5555555555555555), C=Uint32(0x44556677)), - ] - ) - element_root_0 = fixed_root(b"\xcc", b"\x42" * 8, b"\x37\x13\x37\x13") - element_root_1 = fixed_root(b"\xdd", b"\x33" * 8, b"\xcd\xab\xcd\xab") - element_root_2 = fixed_root(b"\xee", b"\x44" * 8, b"\x33\x22\x11\x00") - element_root_3 = fixed_root(b"\xff", b"\x55" * 8, b"\x77\x66\x55\x44") - assert hash_tree_root(fixed_vector) == h( - h(element_root_0, element_root_1), h(element_root_2, element_root_3) - ) - - -def test_hash_tree_root_vector_of_variable_containers() -> None: - """A vector of variable-size containers still hashes the per-element roots.""" - - def var_root(a: bytes, payload: bytes, count: int, c: bytes) -> Bytes32: - base = merge(pad(payload), Z[0:6]) - b_root = h(base, pad(count.to_bytes(32, "little"))) - return h(h(pad(a), b_root), h(pad(c), Z[0])) - - variable_vector = VarVector2( - data=[ - Var( - A=Uint16(0xDEAD), - B=Uint16List1024(data=(Uint16(1), Uint16(2), Uint16(3))), - C=Uint8(0x11), - ), - Var( - A=Uint16(0xBEEF), - B=Uint16List1024(data=(Uint16(4), Uint16(5), Uint16(6))), - C=Uint8(0x22), - ), - ] - ) - element_root_0 = var_root(b"\xad\xde", b"\x01\x00\x02\x00\x03\x00", 3, b"\x11") - element_root_1 = var_root(b"\xef\xbe", b"\x04\x00\x05\x00\x06\x00", 3, b"\x22") - assert hash_tree_root(variable_vector) == h(element_root_0, element_root_1) - - -@pytest.mark.parametrize( - "unsupported_value", - [ - 42, - "hello", - [1, 2, 3], - {"k": 1}, - (1, 2), - 3.14, - None, - ], - ids=["int", "str", "list", "dict", "tuple", "float", "none"], -) -def test_hash_tree_root_unsupported_type_raises(unsupported_value: object) -> None: - """The dispatch fallback rejects values without a registered handler.""" - with pytest.raises(TypeError) as exception_info: - hash_tree_root(unsupported_value) - assert str(exception_info.value) == ( - f"hash_tree_root: unsupported value type {type(unsupported_value).__name__}" - ) - - -def test_hash_tree_root_is_deterministic() -> None: - """Repeated calls on equal inputs return byte-identical roots.""" - first_list = Uint16List1024(data=(Uint16(1), Uint16(2), Uint16(3))) - second_list = Uint16List1024(data=(Uint16(1), Uint16(2), Uint16(3))) - assert hash_tree_root(first_list) == hash_tree_root(second_list) - - -def test_hash_tree_root_distinguishes_by_length() -> None: - """Variable-length types with the same data but different lengths produce different roots.""" - short_list = Uint16List1024(data=(Uint16(1), Uint16(2))) - long_list = Uint16List1024(data=(Uint16(1), Uint16(2), Uint16(0))) - assert hash_tree_root(short_list) != hash_tree_root(long_list) diff --git a/tests/spec/crypto/xmss/test_constants.py b/tests/spec/crypto/xmss/test_constants.py index 202a8d440..a076e07aa 100644 --- a/tests/spec/crypto/xmss/test_constants.py +++ b/tests/spec/crypto/xmss/test_constants.py @@ -17,6 +17,7 @@ import math import pytest +from ssz import BYTES_PER_LENGTH_OFFSET, Uint64 from lean_spec.spec.crypto.koalabear import P_BYTES, P from lean_spec.spec.crypto.xmss.constants import ( @@ -25,8 +26,6 @@ TEST_CONFIG, XmssConfig, ) -from lean_spec.spec.ssz import Uint64 -from lean_spec.spec.ssz.ssz_base import BYTES_PER_LENGTH_OFFSET def _valid_config_kwargs() -> dict[str, int]: diff --git a/tests/spec/crypto/xmss/test_containers.py b/tests/spec/crypto/xmss/test_containers.py index 39733e6ea..ae0b307f5 100644 --- a/tests/spec/crypto/xmss/test_containers.py +++ b/tests/spec/crypto/xmss/test_containers.py @@ -5,6 +5,7 @@ import pytest from pydantic import ValidationError +from ssz import BYTES_PER_LENGTH_OFFSET, Uint32, Uint64 from consensus_testing.keys import XmssKeyManager from lean_spec.spec.crypto.koalabear import P_BYTES @@ -19,10 +20,7 @@ from lean_spec.spec.crypto.xmss.interface import TEST_SIGNATURE_SCHEME from lean_spec.spec.crypto.xmss.types import HashDigestList, HashTreeOpening from lean_spec.spec.forks import Slot, ValidatorIndex -from lean_spec.spec.ssz import Bytes32, Uint64 -from lean_spec.spec.ssz.exceptions import SSZSerializationError -from lean_spec.spec.ssz.ssz_base import BYTES_PER_LENGTH_OFFSET -from lean_spec.spec.ssz.uint import Uint32 +from lean_spec.spec.ssz_types import Bytes32, ContainerInvariantError @pytest.fixture(scope="module") @@ -259,8 +257,8 @@ def test_keypair_rejects_invalid_public_key_hex(keypair_a: KeyPair) -> None: with pytest.raises( ValidationError, match=r"(?s)^1 validation error for KeyPair\npublic_key\n" - r" Value error, invalid PublicKey hex: " - r"Value 4022250974 exceeds field modulus 2130706433 .*\Z", + r" Value error, root\[0\]: " + r"4022250974 is out of range for Fp \[0, 2130706432\] .*\Z", ): KeyPair.model_validate( { @@ -275,7 +273,7 @@ def test_keypair_rejects_invalid_secret_key_hex(keypair_a: KeyPair) -> None: with pytest.raises( ValidationError, match=r"(?s)^1 validation error for KeyPair\nsecret_key\n" - r" Value error, invalid SecretKey hex: PRFKey: expected 32 bytes, got 4 .*\Z", + r" Value error, prf_key: PRFKey needs 32 bytes, the input holds 4 .*\Z", ): KeyPair.model_validate( { @@ -367,7 +365,7 @@ def test_signature_rejects_too_few_hashes(sample_signature: Signature) -> None: # well-formed payload that decodes to one fewer hash than the scheme dimension. one_digest_bytes = TEST_CONFIG.HASH_LENGTH_FIELD_ELEMENTS * P_BYTES truncated = sample_signature.encode_bytes()[:-one_digest_bytes] - with pytest.raises(SSZSerializationError) as exception_info: + with pytest.raises(ContainerInvariantError) as exception_info: Signature.decode_bytes(truncated) assert str(exception_info.value) == "Signature.hashes requires exactly 4 hashes, got 3" @@ -392,6 +390,6 @@ def test_signature_rejects_too_many_siblings(sample_signature: Signature) -> Non Uint32(fixed_part_length + len(path_bytes)).serialize(stream) stream.write(path_bytes) stream.write(hashes_bytes) - with pytest.raises(SSZSerializationError) as exception_info: + with pytest.raises(ContainerInvariantError) as exception_info: Signature.decode_bytes(stream.getvalue()) assert str(exception_info.value) == "Signature.path.siblings requires exactly 8 siblings, got 9" diff --git a/tests/spec/crypto/xmss/test_encoding.py b/tests/spec/crypto/xmss/test_encoding.py index 05a974d00..eaa6485e2 100644 --- a/tests/spec/crypto/xmss/test_encoding.py +++ b/tests/spec/crypto/xmss/test_encoding.py @@ -1,6 +1,7 @@ """Tests for the message-to-codeword encoding pipeline.""" import pytest +from ssz import Uint64 from lean_spec.spec.crypto.koalabear import Fp, P from lean_spec.spec.crypto.xmss import encoding @@ -15,7 +16,7 @@ from lean_spec.spec.crypto.xmss.field import int_to_base_p, random_field_elements from lean_spec.spec.crypto.xmss.poseidon import POSEIDON from lean_spec.spec.crypto.xmss.types import Parameter, Randomness -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 def _parameter() -> Parameter: diff --git a/tests/spec/crypto/xmss/test_interface.py b/tests/spec/crypto/xmss/test_interface.py index 5eed43dec..adec0240f 100644 --- a/tests/spec/crypto/xmss/test_interface.py +++ b/tests/spec/crypto/xmss/test_interface.py @@ -1,6 +1,7 @@ """End-to-end tests for the Generalized XMSS signature scheme and its helpers.""" import pytest +from ssz import Uint64 from lean_spec.spec.crypto.xmss import interface from lean_spec.spec.crypto.xmss.encoding import target_sum_encode @@ -11,7 +12,7 @@ ) from lean_spec.spec.crypto.xmss.types import HashDigestList from lean_spec.spec.forks import Slot -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 def _test_correctness_roundtrip( diff --git a/tests/spec/crypto/xmss/test_merkle.py b/tests/spec/crypto/xmss/test_merkle.py index f7efca34d..d932bc993 100644 --- a/tests/spec/crypto/xmss/test_merkle.py +++ b/tests/spec/crypto/xmss/test_merkle.py @@ -1,6 +1,8 @@ """Tests for the sparse Merkle subtree implementation.""" import pytest +from pydantic import ValidationError +from ssz import Uint64 from lean_spec.spec.crypto.xmss.constants import PROD_CONFIG, TEST_CONFIG, XmssConfig from lean_spec.spec.crypto.xmss.field import random_domain, random_parameter @@ -20,8 +22,6 @@ Parameter, TreeTweak, ) -from lean_spec.spec.ssz import Uint64 -from lean_spec.spec.ssz.exceptions import SSZValueError def _run_commit_open_verify_roundtrip( @@ -364,7 +364,7 @@ def test_verify_path_rejects_excessive_depth_at_ssz_level() -> None: The defensive depth guard inside verification cannot be reached through a well-formed opening, since the digest list rejects more than its limit. """ - with pytest.raises(SSZValueError): + with pytest.raises(ValidationError): HashDigestList(data=[random_domain(PROD_CONFIG) for _ in range(33)]) diff --git a/tests/spec/crypto/xmss/test_poseidon.py b/tests/spec/crypto/xmss/test_poseidon.py index 413812f4c..09a175770 100644 --- a/tests/spec/crypto/xmss/test_poseidon.py +++ b/tests/spec/crypto/xmss/test_poseidon.py @@ -1,6 +1,7 @@ """Tests for the Poseidon hash engine wrapper used by the XMSS scheme.""" import pytest +from ssz import Uint64 from lean_spec.spec.crypto.koalabear import Fp from lean_spec.spec.crypto.xmss.constants import TEST_CONFIG @@ -12,7 +13,6 @@ Parameter, TreeTweak, ) -from lean_spec.spec.ssz import Uint64 def _parameter() -> Parameter: diff --git a/tests/spec/crypto/xmss/test_prf.py b/tests/spec/crypto/xmss/test_prf.py index 57d64d216..23bfd11c7 100644 --- a/tests/spec/crypto/xmss/test_prf.py +++ b/tests/spec/crypto/xmss/test_prf.py @@ -1,12 +1,14 @@ """Tests for the SHAKE128-based pseudorandom function (PRF).""" +from ssz import Uint64 + from lean_spec.spec.crypto.koalabear import Fp, P from lean_spec.spec.crypto.xmss.constants import ( PRF_KEY_LENGTH, TEST_CONFIG, ) from lean_spec.spec.crypto.xmss.prf import PRFKey -from lean_spec.spec.ssz import Bytes32, Uint64 +from lean_spec.spec.ssz_types import Bytes32 def test_key_gen_is_random() -> None: diff --git a/tests/spec/crypto/xmss/test_types.py b/tests/spec/crypto/xmss/test_types.py index b0302375b..0676b3b12 100644 --- a/tests/spec/crypto/xmss/test_types.py +++ b/tests/spec/crypto/xmss/test_types.py @@ -1,6 +1,8 @@ """Tests for the base SSZ types of the XMSS signature scheme.""" import pytest +from pydantic import ValidationError +from ssz import Uint64 from lean_spec.spec.crypto.koalabear import Fp from lean_spec.spec.crypto.xmss.constants import TEST_CONFIG @@ -15,8 +17,6 @@ Randomness, TreeTweak, ) -from lean_spec.spec.ssz import Uint64 -from lean_spec.spec.ssz.exceptions import SSZValueError def test_tree_tweak_fields() -> None: @@ -42,12 +42,12 @@ def test_hash_digest_vector_length_is_digest_length() -> None: def test_hash_digest_vector_accepts_exact_length() -> None: """A digest vector of the configured length validates.""" digest_elements = [Fp(value=i) for i in range(TEST_CONFIG.HASH_LENGTH_FIELD_ELEMENTS)] - assert HashDigestVector(data=digest_elements).data == tuple(digest_elements) + assert list(HashDigestVector(data=digest_elements)) == digest_elements def test_hash_digest_vector_rejects_wrong_length() -> None: """A digest vector of the wrong length fails validation.""" - with pytest.raises(SSZValueError): + with pytest.raises(ValidationError): HashDigestVector(data=[Fp(value=0)] * (TEST_CONFIG.HASH_LENGTH_FIELD_ELEMENTS + 1)) @@ -75,7 +75,7 @@ def test_hash_digest_list_accepts_limit_entries() -> None: def test_hash_digest_list_rejects_over_limit() -> None: """A digest list one entry past the cap fails validation.""" nodes = [random_domain(TEST_CONFIG) for _ in range(NODE_LIST_LIMIT + 1)] - with pytest.raises(SSZValueError): + with pytest.raises(ValidationError): HashDigestList(data=nodes) diff --git a/tests/spec/ssz/test_bitfields.py b/tests/spec/ssz/test_bitfields.py deleted file mode 100644 index 769d2878e..000000000 --- a/tests/spec/ssz/test_bitfields.py +++ /dev/null @@ -1,501 +0,0 @@ -"""Tests for the Bitvector and Bitlist types.""" - -import io -from typing import Any - -import pytest -from hypothesis import given, strategies as st -from pydantic import BaseModel, ValidationError - -from lean_spec.spec.ssz.bitfields import BaseBitlist, BaseBitvector -from lean_spec.spec.ssz.boolean import Boolean -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError, SSZValueError - -# Errors that may be raised either directly or wrapped by Pydantic at construction time. -ValueOrValidationError = (SSZValueError, ValidationError) - - -class Bitvector4(BaseBitvector): - """A bitvector of exactly 4 bits.""" - - LENGTH = 4 - - -class Bitvector4Model(BaseModel): - """Model for testing Pydantic validation of Bitvector4.""" - - value: Bitvector4 - - -class Bitlist8(BaseBitlist): - """A bitlist with up to 8 bits.""" - - LIMIT = 8 - - -class Bitlist8Model(BaseModel): - """Model for testing Pydantic validation of Bitlist8.""" - - value: Bitlist8 - - -class TestBitvector: - """Tests for the fixed-length Bitvector type.""" - - def test_class_creates_specialized_type(self) -> None: - """Concrete Bitvector classes carry the declared length.""" - - class Bitvector8(BaseBitvector): - LENGTH = 8 - - class Bitvector16(BaseBitvector): - LENGTH = 16 - - assert Bitvector8.LENGTH == 8 - assert Bitvector16.LENGTH == 16 - assert "Bitvector8" in repr(Bitvector8) - - def test_instantiate_raw_type_raises_error(self) -> None: - """Direct instantiation of the abstract base raises SSZTypeError.""" - with pytest.raises(SSZTypeError) as exception_info: - BaseBitvector(data=[]) - assert str(exception_info.value) == "BaseBitvector must define LENGTH" - - def test_instantiation_success(self) -> None: - """Instantiation succeeds with exactly LENGTH boolean items.""" - instance = Bitvector4(data=[Boolean(True), Boolean(False), Boolean(1), Boolean(0)]) - assert len(instance) == 4 - assert instance == Bitvector4( - data=[Boolean(True), Boolean(False), Boolean(True), Boolean(False)] - ) - - def test_instantiation_from_generator(self) -> None: - """Fixed-length type materializes a generator into a tuple before validation.""" - bit_generator = (Boolean(bit) for bit in [True, False, True, False]) - instance = Bitvector4(data=bit_generator) # type: ignore[arg-type] - assert len(instance) == 4 - - @pytest.mark.parametrize( - "bits, expected_element_count", - [ - ([Boolean(True), Boolean(False), Boolean(True)], 3), - ( - [Boolean(True), Boolean(False), Boolean(True), Boolean(False), Boolean(True)], - 5, - ), - ], - ) - def test_instantiation_with_wrong_length_raises_error( - self, bits: list[Boolean], expected_element_count: int - ) -> None: - """Wrong-length input raises with the exact element count in the message.""" - with pytest.raises(ValueOrValidationError) as exception_info: - Bitvector4(data=bits) - assert ( - str(exception_info.value) - == f"Bitvector4 requires exactly 4 elements, got {expected_element_count}" - ) - - def test_pydantic_validation_accepts_valid_list(self) -> None: - """Pydantic validation accepts a valid list of booleans.""" - bits = [Boolean(True), Boolean(False), Boolean(True), Boolean(False)] - instance = Bitvector4Model(value={"data": bits}) # type: ignore[arg-type] - assert isinstance(instance.value, Bitvector4) - assert instance.value == Bitvector4(data=bits) - - @pytest.mark.parametrize( - "invalid_value", - [ - {"data": [Boolean(True), Boolean(False), Boolean(True)]}, - {"data": [Boolean(bit) for bit in [True, False, True, False, True]]}, - ], - ) - def test_pydantic_validation_rejects_wrong_length(self, invalid_value: Any) -> None: - """Pydantic validation rejects lists of the wrong length.""" - with pytest.raises(ValueOrValidationError): - Bitvector4Model(value=invalid_value) - - def test_bitvector_is_immutable(self) -> None: - """Item assignment on a Bitvector raises TypeError — Pydantic models are immutable.""" - - class Bitvector2(BaseBitvector): - LENGTH = 2 - - vec = Bitvector2(data=[Boolean(True), Boolean(False)]) - with pytest.raises(TypeError): - vec[0] = False # type: ignore[index] - - -class TestBitlist: - """Tests for the variable-length Bitlist type.""" - - def test_class_creates_specialized_type(self) -> None: - """Concrete Bitlist classes carry the declared limit.""" - - class Bitlist8(BaseBitlist): - LIMIT = 8 - - class Bitlist16(BaseBitlist): - LIMIT = 16 - - assert Bitlist8.LIMIT == 8 - assert Bitlist16.LIMIT == 16 - assert "Bitlist8" in repr(Bitlist8) - - def test_instantiate_raw_type_raises_error(self) -> None: - """Direct instantiation of the abstract base raises SSZTypeError.""" - with pytest.raises(SSZTypeError) as exception_info: - BaseBitlist(data=[]) - assert str(exception_info.value) == "BaseBitlist must define LIMIT" - - def test_instantiation_success(self) -> None: - """Instantiation succeeds with any number of items up to LIMIT.""" - instance = Bitlist8(data=[Boolean(True), Boolean(False), Boolean(1), Boolean(0)]) - assert len(instance) == 4 - expected_bitlist = Bitlist8( - data=[Boolean(True), Boolean(False), Boolean(True), Boolean(False)] - ) - assert instance == expected_bitlist - - def test_instantiation_from_generator(self) -> None: - """Variable-length type materializes a generator into a list before validation.""" - bit_generator = (Boolean(bit) for bit in [True, False, True]) - instance = Bitlist8(data=bit_generator) # type: ignore[arg-type] - assert len(instance) == 3 - - @pytest.mark.parametrize( - "non_iterable, type_name", - [ - (42, "int"), - (None, "NoneType"), - (1.5, "float"), - ], - ) - def test_instantiation_from_non_iterable_raises( - self, non_iterable: Any, type_name: str - ) -> None: - """Non-iterable input raises SSZTypeError naming the offending type.""" - with pytest.raises((SSZTypeError, ValidationError)) as exception_info: - Bitlist8(data=non_iterable) - assert str(exception_info.value) == f"Expected iterable, got {type_name}" - - @pytest.mark.parametrize("rejected", ["0101", b"\x00\x01"]) - def test_instantiation_from_str_or_bytes_raises(self, rejected: Any) -> None: - """str and bytes are iterable but explicitly rejected — their elements are not booleans.""" - type_name = type(rejected).__name__ - with pytest.raises((SSZTypeError, ValidationError)) as exception_info: - Bitlist8(data=rejected) - assert str(exception_info.value) == f"Expected iterable, got {type_name}" - - def test_instantiation_over_limit_raises_error(self) -> None: - """Input exceeding LIMIT raises with the exact size in the message.""" - - class Bitlist4(BaseBitlist): - LIMIT = 4 - - with pytest.raises(ValueOrValidationError) as exception_info: - Bitlist4(data=[Boolean(bit) for bit in [True, False, True, False, True]]) - assert str(exception_info.value) == "Bitlist4 exceeds limit of 4, got 5" - - def test_pydantic_validation_accepts_valid_list(self) -> None: - """Pydantic validation accepts a valid list of booleans.""" - bits = [Boolean(True), Boolean(False), Boolean(True), Boolean(False)] - instance = Bitlist8Model(value={"data": bits}) # type: ignore[arg-type] - assert isinstance(instance.value, Bitlist8) - assert len(instance.value) == 4 - - def test_pydantic_validation_rejects_oversized_list(self) -> None: - """Pydantic validation rejects lists exceeding the limit.""" - invalid_value = {"data": [Boolean(True)] * 9} - with pytest.raises(ValueOrValidationError): - Bitlist8Model(value=invalid_value) # type: ignore[arg-type] - - def test_get_item_int(self) -> None: - """Indexing by int returns the Boolean at that position.""" - bitlist = Bitlist8(data=[Boolean(True), Boolean(False), Boolean(True)]) - assert bitlist[0] == Boolean(True) - assert bitlist[1] == Boolean(False) - assert bitlist[2] == Boolean(True) - - def test_get_item_slice(self) -> None: - """Indexing by slice returns a list of Booleans.""" - bitlist = Bitlist8(data=[Boolean(True), Boolean(False), Boolean(True), Boolean(False)]) - sliced_bits = bitlist[1:3] - assert sliced_bits == [Boolean(False), Boolean(True)] - assert isinstance(sliced_bits, list) - - def test_add_with_list(self) -> None: - """Concatenating a Bitlist with a list returns a new instance.""" - bitlist = Bitlist8(data=[Boolean(True), Boolean(False), Boolean(True)]) - concatenated = bitlist + [Boolean(False), Boolean(True)] - assert len(concatenated) == 5 - assert list(concatenated.data) == [ - Boolean(True), - Boolean(False), - Boolean(True), - Boolean(False), - Boolean(True), - ] - assert isinstance(concatenated, Bitlist8) - - def test_add_with_bitlist(self) -> None: - """Concatenating two Bitlists of the same type returns a new instance.""" - bitlist1 = Bitlist8(data=[Boolean(True), Boolean(False)]) - bitlist2 = Bitlist8(data=[Boolean(True), Boolean(True)]) - concatenated = bitlist1 + bitlist2 - assert len(concatenated) == 4 - assert list(concatenated.data) == [ - Boolean(True), - Boolean(False), - Boolean(True), - Boolean(True), - ] - assert isinstance(concatenated, Bitlist8) - - def test_add_with_unsupported_type_raises(self) -> None: - """Adding an unsupported type returns NotImplemented and Python raises TypeError.""" - bitlist = Bitlist8(data=[Boolean(True)]) - with pytest.raises(TypeError): - _ = bitlist + 42 - - def test_add_exceeding_limit_raises_error(self) -> None: - """Concatenation beyond LIMIT raises with the exact size in the message.""" - - class Bitlist4(BaseBitlist): - LIMIT = 4 - - bitlist = Bitlist4(data=[Boolean(True), Boolean(False), Boolean(True)]) - with pytest.raises(ValueOrValidationError) as exception_info: - _ = bitlist + [Boolean(False), Boolean(True)] - assert str(exception_info.value) == "Bitlist4 exceeds limit of 4, got 5" - - -class TestBitfieldSSZ: - """SSZ interface methods and end-to-end serialization round-trips.""" - - def test_bitvector_is_fixed_size(self) -> None: - """Bitvector reports fixed-size and computes byte length via ceil(LENGTH / 8).""" - - class Bitvector10(BaseBitvector): - LENGTH = 10 - - assert Bitvector10.is_fixed_size() is True - assert Bitvector10.get_byte_length() == 2 - - def test_bitlist_is_variable_size(self) -> None: - """Bitlist reports variable-size and get_byte_length raises.""" - - class Bitlist10(BaseBitlist): - LIMIT = 10 - - assert Bitlist10.is_fixed_size() is False - with pytest.raises(SSZTypeError) as exception_info: - Bitlist10.get_byte_length() - assert ( - str(exception_info.value) == "Bitlist10: variable-size bitlist has no fixed byte length" - ) - - @pytest.mark.parametrize( - "length, bits, expected_hex", - [ - (8, (1, 1, 0, 1, 0, 1, 0, 0), "2b"), - (4, (0, 1, 0, 1), "0a"), - (3, (0, 1, 0), "02"), - (10, (1, 0, 1, 0, 0, 0, 1, 1, 0, 1), "c502"), - (16, (1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1), "c5c2"), - (512, tuple([1] * 512), "ff" * 64), - (513, tuple([1] * 513), ("ff" * 64) + "01"), - ], - ) - def test_bitvector_round_trip( - self, length: int, bits: tuple[int, ...], expected_hex: str - ) -> None: - """Bitvector round-trips through encode_bytes, decode_bytes, and stream serialization.""" - - class TestBitvector(BaseBitvector): - LENGTH = length - - boolean_bits = tuple(Boolean(bit) for bit in bits) - instance = TestBitvector(data=boolean_bits) - - encoded = instance.encode_bytes() - assert encoded.hex() == expected_hex - - decoded = TestBitvector.decode_bytes(encoded) - assert decoded == instance - - stream = io.BytesIO() - written = instance.serialize(stream) - assert written == TestBitvector.get_byte_length() - stream.seek(0) - decoded2 = TestBitvector.deserialize(stream, scope=written) - assert decoded2 == instance - - @pytest.mark.parametrize( - "limit, bits, expected_hex", - [ - (8, (), "01"), - (8, (1, 1, 0, 1, 0, 1, 0, 0), "2b01"), - (4, (0, 1, 0, 1), "1a"), - (3, (0, 1, 0), "0a"), - (16, (1, 0, 1, 0, 0, 0, 1, 1, 0, 1), "c506"), - (512, (1,), "03"), - (512, tuple([1] * 512), ("ff" * 64) + "01"), - (513, tuple([1] * 513), ("ff" * 64) + "03"), - ], - ) - def test_bitlist_round_trip(self, limit: int, bits: tuple[int, ...], expected_hex: str) -> None: - """Bitlist round-trips through encode_bytes, decode_bytes, and stream serialization.""" - - class TestBitlist(BaseBitlist): - LIMIT = limit - - boolean_bits = tuple(Boolean(bit) for bit in bits) - instance = TestBitlist(data=boolean_bits) - - encoded = instance.encode_bytes() - assert encoded.hex() == expected_hex - - decoded = TestBitlist.decode_bytes(encoded) - assert decoded == instance - - stream = io.BytesIO() - written = instance.serialize(stream) - assert written == len(encoded) - stream.seek(0) - decoded2 = TestBitlist.deserialize(stream, scope=written) - assert decoded2 == instance - - def test_bitvector_decode_invalid_length(self) -> None: - """Bitvector.decode_bytes rejects inputs whose byte count is wrong.""" - - class Bitvector8(BaseBitvector): - LENGTH = 8 - - with pytest.raises(SSZValueError) as exception_info: - Bitvector8.decode_bytes(b"\x01\x02") - assert str(exception_info.value) == "Bitvector8: expected 1 bytes, got 2" - - def test_bitvector_decode_rejects_non_zero_padding_bits(self) -> None: - """Bitvector.decode_bytes rejects a final byte with set bits above the data bits.""" - - class Bitvector5(BaseBitvector): - LENGTH = 5 - - # Bits 5, 6, 7 are padding above the 5 data bits and must be zero. - # 0b11111111 sets them, so it is a non-canonical encoding of [1] * 5. - with pytest.raises(SSZValueError) as exception_info: - Bitvector5.decode_bytes(b"\xff") - assert str(exception_info.value) == "Bitvector5: non-zero padding bits in final byte 0xff" - - def test_bitvector_decode_canonical_with_zero_padding_bits(self) -> None: - """Bitvector.decode_bytes accepts the canonical encoding with zero padding bits.""" - - class Bitvector5(BaseBitvector): - LENGTH = 5 - - # 0b00011111 holds 5 data bits all set with zero padding above them. - assert Bitvector5.decode_bytes(b"\x1f") == Bitvector5(data=[Boolean(True)] * 5) - - def test_bitvector_deserialize_invalid_scope(self) -> None: - """Bitvector.deserialize rejects a scope mismatching the type's byte length.""" - - class Bitvector8(BaseBitvector): - LENGTH = 8 - - stream = io.BytesIO(b"\xff") - with pytest.raises(SSZSerializationError) as exception_info: - Bitvector8.deserialize(stream, scope=2) - assert str(exception_info.value) == "Bitvector8: expected 1 bytes, got 2" - - def test_bitvector_deserialize_premature_end(self) -> None: - """Bitvector.deserialize rejects a stream that ends before the declared scope.""" - - class Bitvector16(BaseBitvector): - LENGTH = 16 - - stream = io.BytesIO(b"\xff") - with pytest.raises(SSZSerializationError) as exception_info: - Bitvector16.deserialize(stream, scope=2) - assert str(exception_info.value) == "Bitvector16: expected 2 bytes, got 1" - - def test_bitlist_decode_empty_bytes(self) -> None: - """Bitlist.decode_bytes rejects an empty byte sequence.""" - - class Bitlist8(BaseBitlist): - LIMIT = 8 - - with pytest.raises(SSZSerializationError) as exception_info: - Bitlist8.decode_bytes(b"") - assert str(exception_info.value) == "Bitlist8: cannot decode empty bytes" - - def test_bitlist_decode_all_zero_bytes(self) -> None: - """Bitlist.decode_bytes rejects non-empty input with no 1 bits — no delimiter to locate.""" - - class Bitlist8(BaseBitlist): - LIMIT = 8 - - with pytest.raises(SSZSerializationError) as exception_info: - Bitlist8.decode_bytes(b"\x00") - assert str(exception_info.value) == "Bitlist8: no delimiter bit found" - - def test_bitlist_decode_rejects_non_canonical_trailing_zero_byte(self) -> None: - """Bitlist.decode_bytes rejects a trailing zero byte after the delimiter byte.""" - - class Bitlist8(BaseBitlist): - LIMIT = 8 - - # Byte 0x0d encodes bits [1, 0, 1] with the delimiter at bit 3. - # Appending a zero byte leaves the delimiter in byte 0, not the final byte. - with pytest.raises(SSZSerializationError) as exception_info: - Bitlist8.decode_bytes(b"\x0d\x00") - assert ( - str(exception_info.value) - == "Bitlist8: non-canonical trailing zero bytes after delimiter" - ) - - def test_bitlist_decode_canonical_encoding_round_trips(self) -> None: - """Bitlist.decode_bytes accepts the canonical single-byte encoding of bits [1, 0, 1].""" - - class Bitlist8(BaseBitlist): - LIMIT = 8 - - assert Bitlist8.decode_bytes(b"\x0d") == Bitlist8( - data=(Boolean(True), Boolean(False), Boolean(True)) - ) - - def test_bitlist_decode_exceeds_limit(self) -> None: - """Bitlist.decode_bytes rejects encodings whose recovered bit count exceeds LIMIT.""" - - class Bitlist8(BaseBitlist): - LIMIT = 8 - - # Bytes [0xFF, 0xFF, 0x01] mean 16 data bits + delimiter at bit 16 — > LIMIT=8. - with pytest.raises(SSZValueError) as exception_info: - Bitlist8.decode_bytes(b"\xff\xff\x01") - assert str(exception_info.value) == "Bitlist8 exceeds limit of 8, got 16" - - def test_bitlist_deserialize_premature_end(self) -> None: - """Bitlist.deserialize rejects a stream that ends before the declared scope.""" - - class Bitlist16(BaseBitlist): - LIMIT = 16 - - stream = io.BytesIO(b"\xff") - with pytest.raises(SSZSerializationError) as exception_info: - Bitlist16.deserialize(stream, scope=2) - assert str(exception_info.value) == "Bitlist16: expected 2 bytes, got 1" - - -@given(bits=st.lists(st.booleans(), max_size=8)) -def test_bitlist_round_trip_random_bits(bits: list[bool]) -> None: - """Any bit pattern up to the limit, including empty, round-trips unchanged.""" - instance = Bitlist8(data=tuple(Boolean(bit) for bit in bits)) - assert Bitlist8.decode_bytes(instance.encode_bytes()) == instance - - -@given(bits=st.lists(st.booleans(), min_size=4, max_size=4)) -def test_bitvector_round_trip_random_bits(bits: list[bool]) -> None: - """Any fixed-length bit pattern round-trips unchanged.""" - instance = Bitvector4(data=tuple(Boolean(bit) for bit in bits)) - assert Bitvector4.decode_bytes(instance.encode_bytes()) == instance diff --git a/tests/spec/ssz/test_boolean.py b/tests/spec/ssz/test_boolean.py deleted file mode 100644 index 9ce0d8683..000000000 --- a/tests/spec/ssz/test_boolean.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Tests for the Boolean Type.""" - -import io -from typing import Any, Callable - -import pytest -from hypothesis import given, strategies as st -from pydantic import BaseModel, ValidationError - -from lean_spec.spec.ssz.boolean import Boolean -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError, SSZValueError - - -class BooleanModel(BaseModel): - """Model for testing Pydantic validation of Boolean.""" - - value: Boolean - - -@pytest.mark.parametrize("valid_value", [True, False]) -def test_pydantic_validation_accepts_valid_bool(valid_value: bool) -> None: - """Tests that Pydantic validation correctly accepts a valid boolean.""" - instance = BooleanModel(value=valid_value) # type: ignore[arg-type] - assert isinstance(instance.value, Boolean) - assert instance.value == Boolean(valid_value) - - -@pytest.mark.parametrize("invalid_value", [1, 0, 1.0, "True"]) -def test_pydantic_strict_mode_rejects_invalid_types(invalid_value: Any) -> None: - """Tests that Pydantic's strict mode rejects types that are not `bool`.""" - with pytest.raises(ValidationError): - BooleanModel(value=invalid_value) - - -def test_pydantic_accepts_existing_boolean_instance() -> None: - """Pydantic schema accepts an already-typed Boolean instance via the is_instance branch.""" - instance = BooleanModel(value=Boolean(True)) - assert isinstance(instance.value, Boolean) - assert int(instance.value) == 1 - - -def test_pydantic_serializes_boolean_to_plain_bool() -> None: - """Pydantic serializes Boolean back to a plain bool for JSON output.""" - serialized = BooleanModel(value=True).model_dump() # type: ignore[arg-type] - assert serialized == {"value": True} - assert type(serialized["value"]) is bool - - -@pytest.mark.parametrize("valid_value", [True, False, 1, 0]) -def test_instantiation_from_valid_types(valid_value: bool | int) -> None: - """Tests that a Boolean can be instantiated from valid bools and ints.""" - boolean_instance = Boolean(valid_value) - assert int(boolean_instance) == int(valid_value) - - -@pytest.mark.parametrize("invalid_int", [-1, 2, 100]) -def test_instantiation_from_invalid_int_raises_error(invalid_int: int) -> None: - """Tests that instantiating with an int other than 0 or 1 raises SSZValueError.""" - with pytest.raises(SSZValueError) as exception_info: - Boolean(invalid_int) - assert str(exception_info.value) == f"Boolean value must be 0 or 1, not {invalid_int}" - - -@pytest.mark.parametrize("invalid_type", [1.0, "True", b"\x01", None]) -def test_instantiation_from_invalid_types_raises_error(invalid_type: Any) -> None: - """Tests that instantiating with non-bool/non-int types raises SSZTypeError.""" - name = type(invalid_type).__name__ - with pytest.raises(SSZTypeError) as exception_info: - Boolean(invalid_type) - assert str(exception_info.value) == f"Expected bool or int, got {name}" - - -def test_wrapping_existing_boolean_succeeds() -> None: - """Boolean(Boolean(x)) must succeed — int() in __new__ avoids the strict __eq__ trap.""" - outer = Boolean(Boolean(True)) - assert isinstance(outer, Boolean) - assert int(outer) == 1 - - -def test_instantiation_and_type() -> None: - """Tests that a Boolean is an instance of `int` and its own class.""" - boolean = Boolean(True) - assert isinstance(boolean, int) - assert isinstance(boolean, Boolean) - - -@pytest.mark.parametrize( - "op", - [ - lambda a, b: a + b, - lambda a, b: a - b, - lambda a, b: 1 + b, - lambda a, b: 1 - b, - ], -) -def test_arithmetic_operators_raise_error(op: Callable[[Any, Any], Any]) -> None: - """Tests that all arithmetic operators are disabled and raise TypeError.""" - with pytest.raises(TypeError) as exception_info: - op(Boolean(True), Boolean(False)) - assert str(exception_info.value) == "Arithmetic operations are not supported for Boolean." - - -def test_bitwise_operators() -> None: - """Tests all standard bitwise operators between Boolean instances.""" - b_true = Boolean(True) - b_false = Boolean(False) - - assert b_true & b_true == b_true - assert b_true & b_false == b_false - assert b_true | b_false == b_true - assert b_false | b_false == b_false - assert b_true ^ b_true == b_false - assert b_true ^ b_false == b_true - - -@pytest.mark.parametrize("invalid_operand", [1, True, 0.0, "a"]) -def test_bitwise_operators_with_other_types_raise_error(invalid_operand: Any) -> None: - """Tests that bitwise operations with non-Boolean types raise TypeError.""" - name = type(invalid_operand).__name__ - with pytest.raises(TypeError) as exception_info: - _ = Boolean(True) & invalid_operand - assert str(exception_info.value) == f"Unsupported operand type(s) for &: 'Boolean' and '{name}'" - with pytest.raises(TypeError) as exception_info: - _ = Boolean(True) | invalid_operand - assert str(exception_info.value) == f"Unsupported operand type(s) for |: 'Boolean' and '{name}'" - with pytest.raises(TypeError) as exception_info: - _ = Boolean(True) ^ invalid_operand - assert str(exception_info.value) == f"Unsupported operand type(s) for ^: 'Boolean' and '{name}'" - - -@pytest.mark.parametrize("other", [1, 0, "x", 1.0, None]) -def test_reverse_bitwise_with_other_types_raise(other: Any) -> None: - """Bitwise ops with a non-Boolean LHS raise TypeError via the reflected dunder.""" - name = type(other).__name__ - with pytest.raises(TypeError) as exception_info: - _ = other & Boolean(True) - assert str(exception_info.value) == f"Unsupported operand type(s) for &: 'Boolean' and '{name}'" - with pytest.raises(TypeError) as exception_info: - _ = other | Boolean(True) - assert str(exception_info.value) == f"Unsupported operand type(s) for |: 'Boolean' and '{name}'" - with pytest.raises(TypeError) as exception_info: - _ = other ^ Boolean(True) - assert str(exception_info.value) == f"Unsupported operand type(s) for ^: 'Boolean' and '{name}'" - - -@pytest.mark.parametrize( - "left, right, expected", - [ - (Boolean(True), Boolean(True), True), - (Boolean(False), Boolean(False), True), - (Boolean(True), Boolean(False), False), - (Boolean(False), Boolean(True), False), - ], -) -def test_equality_same_type(left: Boolean, right: Boolean, expected: bool) -> None: - """Boolean == Boolean returns True or False by value.""" - assert (left == right) is expected - - -@pytest.mark.parametrize( - "left, right, expected", - [ - (Boolean(True), Boolean(True), False), - (Boolean(False), Boolean(False), False), - (Boolean(True), Boolean(False), True), - (Boolean(False), Boolean(True), True), - ], -) -def test_inequality_same_type(left: Boolean, right: Boolean, expected: bool) -> None: - """Boolean != Boolean returns True or False by value.""" - assert (left != right) is expected - - -@pytest.mark.parametrize("other", [True, False, 1, 0, "a string", 1.0, None]) -def test_equality_cross_type_raises(other: Any) -> None: - """Boolean compared to any non-Boolean value raises TypeError on the LHS.""" - name = type(other).__name__ - with pytest.raises(TypeError) as exception_info: - _ = Boolean(True) == other - assert ( - str(exception_info.value) == f"Unsupported operand type(s) for ==: 'Boolean' and '{name}'" - ) - - -@pytest.mark.parametrize("other", [True, False, 1, 0, "a string", 1.0, None]) -def test_inequality_cross_type_raises(other: Any) -> None: - """Boolean != non-Boolean value raises TypeError on the LHS.""" - name = type(other).__name__ - with pytest.raises(TypeError) as exception_info: - _ = Boolean(True) != other - assert ( - str(exception_info.value) == f"Unsupported operand type(s) for !=: 'Boolean' and '{name}'" - ) - - -@pytest.mark.parametrize("other", [1, 0]) -def test_equality_reflected_int_raises(other: int) -> None: - """int == Boolean: Boolean subclasses int so its __eq__ runs first and raises.""" - with pytest.raises(TypeError) as exception_info: - _ = other == Boolean(True) - assert str(exception_info.value) == "Unsupported operand type(s) for ==: 'Boolean' and 'int'" - - -@pytest.mark.parametrize("other", [1, 0]) -def test_inequality_reflected_int_raises(other: int) -> None: - """int != Boolean: Boolean subclasses int so its __ne__ runs first and raises.""" - with pytest.raises(TypeError) as exception_info: - _ = other != Boolean(True) - assert str(exception_info.value) == "Unsupported operand type(s) for !=: 'Boolean' and 'int'" - - -def test_repr_and_str() -> None: - """Tests the string and official representations.""" - assert str(Boolean(True)) == "True" - assert repr(Boolean(True)) == "Boolean(True)" - assert str(Boolean(False)) == "False" - assert repr(Boolean(False)) == "Boolean(False)" - - -def test_hash() -> None: - """Tests that the hash is distinct from a raw bool.""" - assert hash(Boolean(True)) != hash(True) - assert hash(Boolean(False)) != hash(False) - assert hash(Boolean(True)) == hash(Boolean(1)) - assert hash(Boolean(True)) != hash(Boolean(False)) - - -class TestBooleanSSZ: - """Tests for SSZ serialization and deserialization of the Boolean type.""" - - def test_ssz_properties(self) -> None: - """Tests the static SSZ properties of the Boolean type.""" - assert Boolean.is_fixed_size() is True - assert Boolean.get_byte_length() == 1 - - @pytest.mark.parametrize( - "boolean_value, expected_bytes", - [ - (True, b"\x01"), - (False, b"\x00"), - ], - ) - def test_encode_decode_roundtrip(self, boolean_value: bool, expected_bytes: bytes) -> None: - """Tests the encode_bytes and decode_bytes round-trip.""" - boolean_instance = Boolean(boolean_value) - - # Test encoding - encoded = boolean_instance.encode_bytes() - assert encoded == expected_bytes - - # Test decoding - decoded = Boolean.decode_bytes(encoded) - assert decoded == boolean_instance - assert isinstance(decoded, Boolean) - - def test_decode_invalid_length(self) -> None: - """Tests that decode_bytes fails with incorrect byte length.""" - with pytest.raises(SSZSerializationError) as exception_info: - Boolean.decode_bytes(b"") - assert str(exception_info.value) == "Boolean: expected 1 byte, got 0" - with pytest.raises(SSZSerializationError) as exception_info: - Boolean.decode_bytes(b"\x00\x01") - assert str(exception_info.value) == "Boolean: expected 1 byte, got 2" - - def test_decode_invalid_value(self) -> None: - """Tests that decode_bytes fails with an invalid byte value.""" - with pytest.raises(SSZSerializationError) as exception_info: - Boolean.decode_bytes(b"\x02") - assert str(exception_info.value) == "Boolean: byte must be 0x00 or 0x01, got 0x02" - with pytest.raises(SSZSerializationError) as exception_info: - Boolean.decode_bytes(b"\xff") - assert str(exception_info.value) == "Boolean: byte must be 0x00 or 0x01, got 0xff" - - @pytest.mark.parametrize("value", [True, False]) - def test_serialize_deserialize_roundtrip(self, value: bool) -> None: - """Tests the serialize and deserialize round-trip.""" - boolean_instance = Boolean(value) - stream = io.BytesIO() - - # Test serialization - bytes_written = boolean_instance.serialize(stream) - assert bytes_written == 1 - - # Test deserialization - stream.seek(0) - decoded = Boolean.deserialize(stream, scope=1) - assert decoded == boolean_instance - assert isinstance(decoded, Boolean) - - def test_deserialize_invalid_scope(self) -> None: - """Tests that deserialize fails with an incorrect scope.""" - stream = io.BytesIO(b"\x01") - with pytest.raises(SSZSerializationError) as exception_info: - Boolean.deserialize(stream, scope=0) - assert str(exception_info.value) == "Boolean: expected scope of 1, got 0" - - stream.seek(0) - with pytest.raises(SSZSerializationError) as exception_info: - Boolean.deserialize(stream, scope=2) - assert str(exception_info.value) == "Boolean: expected scope of 1, got 2" - - def test_deserialize_premature_stream_end(self) -> None: - """Tests that deserialize fails if the stream ends prematurely.""" - stream = io.BytesIO(b"") # Empty stream - with pytest.raises(SSZSerializationError) as exception_info: - Boolean.deserialize(stream, scope=1) - assert str(exception_info.value) == "Boolean: expected 1 byte, got 0" - - -@given(boolean_value=st.booleans()) -def test_encode_decode_round_trip_random_values(boolean_value: bool) -> None: - """Either truth value survives an encode and decode round trip unchanged.""" - instance = Boolean(boolean_value) - assert Boolean.decode_bytes(instance.encode_bytes()) == instance diff --git a/tests/spec/ssz/test_byte_arrays.py b/tests/spec/ssz/test_byte_arrays.py deleted file mode 100644 index 4ec91508e..000000000 --- a/tests/spec/ssz/test_byte_arrays.py +++ /dev/null @@ -1,515 +0,0 @@ -"""Tests for the BaseBytes and BaseByteList types.""" - -import hashlib -import io -import json -from typing import Any - -import pytest -from hypothesis import given, strategies as st -from pydantic import BaseModel - -from lean_spec.spec.ssz.byte_arrays import ( - ZERO_HASH, - BaseByteList, - BaseBytes, - Bytes4, - Bytes32, -) -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError, SSZValueError - - -class ByteList5(BaseByteList): - """A bytelist with limit 5 for testing.""" - - LIMIT = 5 - - -class ByteList16(BaseByteList): - """A bytelist with limit 16 for testing.""" - - LIMIT = 16 - - -class ModelVectors(BaseModel): - """Pydantic model holding fixed-length byte arrays.""" - - root: Bytes32 - key: Bytes4 - - -class ModelLists(BaseModel): - """Pydantic model holding a variable-length byte list.""" - - payload: ByteList16 - - -class TestBaseBytesConstruction: - """Construction and coercion of fixed-length byte arrays.""" - - def test_inheritance(self) -> None: - """Concrete subclasses inherit from BaseBytes and stay bytes-compatible.""" - assert issubclass(Bytes32, BaseBytes) - assert Bytes32.LENGTH == 32 - byte_array = Bytes32(b"\x00" * 32) - assert isinstance(byte_array, Bytes32) - assert isinstance(byte_array, bytes) - assert len(byte_array) == 32 - - @pytest.mark.parametrize( - "input_value, expected_bytes", - [ - (b"\x00\x01\x02\x03", b"\x00\x01\x02\x03"), - (bytearray(b"\x00\x01\x02\x03"), b"\x00\x01\x02\x03"), - ([0, 1, 2, 3], b"\x00\x01\x02\x03"), - ((i for i in range(4)), b"\x00\x01\x02\x03"), - ("00010203", b"\x00\x01\x02\x03"), - ("0x00010203", b"\x00\x01\x02\x03"), - ], - ) - def test_coercion_from_supported_inputs(self, input_value: Any, expected_bytes: bytes) -> None: - """Bytes, bytearray, iterables, generators, and hex strings all coerce to bytes.""" - coerced = Bytes4(input_value) - assert bytes(coerced) == expected_bytes - - @pytest.mark.parametrize( - "wrong_input, count", - [ - (b"\x00\x01\x02", 3), - ([0, 1, 2], 3), - ("000102", 3), - ], - ) - def test_construction_with_wrong_length_raises(self, wrong_input: Any, count: int) -> None: - """Inputs whose length doesn't match LENGTH raise with the exact element count.""" - with pytest.raises(SSZValueError) as exception_info: - Bytes4(wrong_input) - assert str(exception_info.value) == f"Bytes4 requires exactly 4 bytes, got {count}" - - @pytest.mark.parametrize("bad_input", [42, None, 1.5]) - def test_construction_with_non_coercible_input_raises(self, bad_input: Any) -> None: - """Inputs outside the accepted union raise TypeError naming the offending type.""" - name = type(bad_input).__name__ - with pytest.raises(TypeError) as exception_info: - Bytes4(bad_input) - assert str(exception_info.value) == f"Cannot coerce {name} to bytes" - - def test_construction_without_length_attribute_raises(self) -> None: - """Direct instantiation of the abstract base raises SSZTypeError.""" - with pytest.raises(SSZTypeError) as exception_info: - BaseBytes(b"") - assert str(exception_info.value) == "BaseBytes must define LENGTH" - - def test_zero_factory(self) -> None: - """The zero classmethod returns an instance of LENGTH zero bytes.""" - zero_array = Bytes4.zero() - assert isinstance(zero_array, Bytes4) - assert bytes(zero_array) == b"\x00\x00\x00\x00" - - -class TestBaseBytesEquality: - """Strict equality, inequality, and hashing of fixed-length byte arrays.""" - - def test_same_type_equality(self) -> None: - """Instances with the same value and type compare equal.""" - v1 = Bytes4(b"\x00\x01\x02\x03") - v2 = Bytes4([0, 1, 2, 3]) - v3 = Bytes4("00010203") - assert v1 == v2 == v3 - - def test_same_type_inequality(self) -> None: - """Instances with different values compare unequal.""" - v1 = Bytes4(b"\x00\x00\x00\x00") - v2 = Bytes4(b"\x00\x00\x00\x01") - assert v1 != v2 - - @pytest.mark.parametrize("other", [b"\x00\x01\x02\x03", "string", 1.5, None, 42]) - def test_cross_type_equality_raises(self, other: Any) -> None: - """Comparing with any non-BaseBytes value raises TypeError.""" - name = type(other).__name__ - with pytest.raises(TypeError) as exception_info: - _ = Bytes4(b"\x00\x01\x02\x03") == other - assert ( - str(exception_info.value) - == f"Unsupported operand type(s) for ==: 'Bytes4' and '{name}'" - ) - - @pytest.mark.parametrize("other", [b"\x00\x01\x02\x03", "string", 1.5, None, 42]) - def test_cross_type_inequality_raises(self, other: Any) -> None: - """Inequality with any non-BaseBytes value raises TypeError.""" - name = type(other).__name__ - with pytest.raises(TypeError) as exception_info: - _ = Bytes4(b"\x00\x01\x02\x03") != other - assert ( - str(exception_info.value) - == f"Unsupported operand type(s) for !=: 'Bytes4' and '{name}'" - ) - - def test_hash_distinct_from_raw_bytes(self) -> None: - """The hash binds the value to its concrete type, so equal raw bytes hash differently.""" - byte_array = Bytes4(b"\x00\x01\x02\x03") - assert hash(byte_array) != hash(b"\x00\x01\x02\x03") - - def test_hash_same_for_equal_instances(self) -> None: - """Equal instances of the same type produce the same hash.""" - v1 = Bytes4(b"\x00\x01\x02\x03") - v2 = Bytes4([0, 1, 2, 3]) - v3 = Bytes4("00010203") - assert hash(v1) == hash(v2) == hash(v3) - - -class TestBaseBytesOperations: - """Repr, hex, iteration, indexing, ordering, and concatenation.""" - - def test_repr(self) -> None: - """The repr is the class name with the hex content in parentheses.""" - assert repr(Bytes4(b"\x00\x01\x02\x03")) == "Bytes4(00010203)" - - def test_hex(self) -> None: - """The hex method returns the lowercase hex string.""" - assert Bytes4(b"\x00\x01\x02\x03").hex() == "00010203" - - def test_length_iter_getitem(self) -> None: - """The instance supports len, iteration, and integer indexing.""" - byte_array = Bytes4(b"\x00\x01\x02\x03") - assert len(byte_array) == 4 - assert list(iter(byte_array)) == [0, 1, 2, 3] - assert byte_array[2] == 2 - - def test_concatenation_returns_plain_bytes(self) -> None: - """Concatenation of two instances returns plain bytes.""" - left_array = Bytes4(b"\x00\x00\x00\x01") - right_array = Bytes4(b"\x00\x00\x00\x02") - concatenated = left_array + right_array - assert type(concatenated) is bytes - assert concatenated == b"\x00\x00\x00\x01\x00\x00\x00\x02" - - def test_reverse_concatenation_returns_plain_bytes(self) -> None: - """Concatenation with raw bytes on the left returns plain bytes.""" - byte_array = Bytes4(b"\x00\x00\x00\x01") - concatenated = b"\xff" + byte_array - assert type(concatenated) is bytes - assert concatenated == b"\xff\x00\x00\x00\x01" - - def test_sort_lexicographic(self) -> None: - """Instances sort lexicographically by byte content.""" - smallest = Bytes32(b"\x00" * 31 + b"\x01") - middle = Bytes32(b"\x00" * 31 + b"\x02") - largest = Bytes32(b"\xff" * 32) - assert sorted([largest, middle, smallest]) == [smallest, middle, largest] - - def test_hashlib_compatibility(self) -> None: - """An instance is usable wherever a bytes-like value is expected.""" - byte_array = Bytes32(b"\x01" + b"\x00" * 31) - digest = hashlib.sha256(byte_array).digest() - assert len(digest) == 32 - - -class TestBaseBytesSSZ: - """SSZ interface methods and serialization round-trip.""" - - def test_is_fixed_size(self) -> None: - """BaseBytes subclasses are always fixed-size.""" - assert Bytes32.is_fixed_size() is True - - def test_get_byte_length(self) -> None: - """get_byte_length returns the declared LENGTH.""" - assert Bytes32.get_byte_length() == 32 - assert Bytes4.get_byte_length() == 4 - - @pytest.mark.parametrize( - "cls, payload", - [ - (Bytes4, b"\x00\x01\x02\x03"), - (Bytes32, b"\x11" * 32), - ], - ) - def test_encode_decode_roundtrip(self, cls: type[BaseBytes], payload: bytes) -> None: - """BaseBytes round-trips through encode_bytes, decode_bytes, and stream serialization.""" - byte_array = cls(payload) - assert byte_array.encode_bytes() == payload - assert cls.decode_bytes(payload) == byte_array - - buffer = io.BytesIO() - bytes_written = byte_array.serialize(buffer) - assert bytes_written == len(payload) - - buffer.seek(0) - deserialized = cls.deserialize(buffer, len(payload)) - assert byte_array == deserialized - - def test_deserialize_scope_mismatch_raises(self) -> None: - """deserialize rejects a scope that doesn't match LENGTH.""" - buffer = io.BytesIO(b"\x00\x01\x02\x03") - with pytest.raises(SSZSerializationError) as exception_info: - Bytes4.deserialize(buffer, 3) - assert str(exception_info.value) == "Bytes4: expected 4 bytes, got 3" - - def test_deserialize_stream_truncation_raises(self) -> None: - """deserialize detects when the stream ends before delivering scope bytes.""" - buffer = io.BytesIO(b"\x00\x01") - with pytest.raises(SSZSerializationError) as exception_info: - Bytes4.deserialize(buffer, 4) - assert str(exception_info.value) == "Bytes4: expected 4 bytes, got 2" - - -class TestBaseBytesPydantic: - """Pydantic validation and JSON serialization for fixed-length byte arrays.""" - - def test_accepts_typed_instances_and_supported_inputs(self) -> None: - """Pydantic accepts existing instances built from hex strings or iterables.""" - model = ModelVectors( - root=Bytes32("0x" + "11" * 32), - key=Bytes4([0, 1, 2, 3]), - ) - assert isinstance(model.root, Bytes32) - assert isinstance(model.key, Bytes4) - assert bytes(model.root) == b"\x11" * 32 - assert bytes(model.key) == b"\x00\x01\x02\x03" - - def test_json_serialization_to_hex(self) -> None: - """Serialization uses 0x-prefixed lowercase hex for JSON output.""" - model = ModelVectors( - root=Bytes32("0x" + "11" * 32), - key=Bytes4([0, 1, 2, 3]), - ) - dumped = model.model_dump() - assert dumped["root"] == "0x" + "11" * 32 - assert dumped["key"] == "0x00010203" - - -class TestBaseByteListConstruction: - """Construction and coercion of variable-length byte lists.""" - - def test_inheritance(self) -> None: - """Concrete subclasses carry the declared limit.""" - byte_list = ByteList16(data=b"\x01\x02") - assert isinstance(byte_list, ByteList16) - assert ByteList16.LIMIT == 16 - assert len(byte_list.data) == 2 - - @pytest.mark.parametrize( - "input_value, expected_bytes", - [ - (b"\x00\x01\x02\x03\x04", b"\x00\x01\x02\x03\x04"), - (bytearray(b"\x00\x01\x02\x03\x04"), b"\x00\x01\x02\x03\x04"), - ([0, 1, 2, 3, 4], b"\x00\x01\x02\x03\x04"), - ("0001020304", b"\x00\x01\x02\x03\x04"), - ("0x0001020304", b"\x00\x01\x02\x03\x04"), - ], - ) - def test_coercion_from_supported_inputs(self, input_value: Any, expected_bytes: bytes) -> None: - """Bytes, bytearray, iterables, and hex strings all coerce to bytes.""" - byte_list = ByteList5(data=input_value) - assert byte_list.data == expected_bytes - assert len(byte_list.data) == len(expected_bytes) - - def test_construction_over_limit_raises(self) -> None: - """Input exceeding LIMIT raises with the exact size in the message.""" - with pytest.raises(SSZValueError) as exception_info: - ByteList5(data=b"\x00" * 6) - assert str(exception_info.value) == "ByteList5 exceeds limit of 5, got 6" - - def test_construction_without_limit_attribute_raises(self) -> None: - """Direct instantiation of the abstract base raises SSZTypeError.""" - with pytest.raises(SSZTypeError) as exception_info: - BaseByteList(data=b"") - assert str(exception_info.value) == "BaseByteList must define LIMIT" - - -class TestBaseByteListEquality: - """Strict equality, inequality, and hashing of variable-length byte lists.""" - - def test_same_type_equality(self) -> None: - """Instances with the same value compare equal.""" - v1 = ByteList16(data=b"\x00\x01\x02") - v2 = ByteList16(data=b"\x00\x01\x02") - assert v1 == v2 - - def test_same_type_inequality(self) -> None: - """Instances with different values compare unequal.""" - v1 = ByteList16(data=b"\x00") - v2 = ByteList16(data=b"\x01") - assert v1 != v2 - - @pytest.mark.parametrize("other", [b"\x00\x01\x02", "string", 1.5, None, 42]) - def test_cross_type_equality_raises(self, other: Any) -> None: - """Comparing with any non-BaseByteList value raises TypeError.""" - name = type(other).__name__ - with pytest.raises(TypeError) as exception_info: - _ = ByteList16(data=b"\x00\x01\x02") == other - assert ( - str(exception_info.value) - == f"Unsupported operand type(s) for ==: 'ByteList16' and '{name}'" - ) - - @pytest.mark.parametrize("other", [b"\x00\x01\x02", "string", 1.5, None, 42]) - def test_cross_type_inequality_raises(self, other: Any) -> None: - """Inequality with any non-BaseByteList value raises TypeError.""" - name = type(other).__name__ - with pytest.raises(TypeError) as exception_info: - _ = ByteList16(data=b"\x00\x01\x02") != other - assert ( - str(exception_info.value) - == f"Unsupported operand type(s) for !=: 'ByteList16' and '{name}'" - ) - - def test_hash_includes_type(self) -> None: - """Instances of different bytelist types with the same data hash differently.""" - v1 = ByteList5(data=b"\x00\x01") - v2 = ByteList16(data=b"\x00\x01") - assert hash(v1) != hash(v2) - - def test_hash_same_for_equal_instances(self) -> None: - """Equal instances of the same type produce the same hash.""" - v1 = ByteList16(data=b"\x00\x01\x02") - v2 = ByteList16(data=b"\x00\x01\x02") - assert hash(v1) == hash(v2) - - -class TestBaseByteListOperations: - """Repr, hex, bytes coercion, and concatenation.""" - - def test_repr(self) -> None: - """The repr is the class name with the hex content in parentheses.""" - assert repr(ByteList16(data=b"\x00\x01\x02")) == "ByteList16(000102)" - - def test_hex(self) -> None: - """The hex method returns the lowercase hex string.""" - assert ByteList16(data=b"\x00\x01\x02").hex() == "000102" - - def test_bytes_dunder(self) -> None: - """Calling bytes() on an instance returns the underlying bytes.""" - assert bytes(ByteList16(data=b"\x00\x01\x02")) == b"\x00\x01\x02" - - def test_concatenation_returns_plain_bytes(self) -> None: - """Concatenation with a bytes-like value returns plain bytes.""" - byte_list = ByteList16(data=b"\x00\x01\x02") - concatenated = byte_list + b"\x03\x04" - assert type(concatenated) is bytes - assert concatenated == b"\x00\x01\x02\x03\x04" - - def test_reverse_concatenation_returns_plain_bytes(self) -> None: - """Concatenation with raw bytes on the left returns plain bytes.""" - byte_list = ByteList16(data=b"\x00\x01") - concatenated = b"\xff" + byte_list - assert type(concatenated) is bytes - assert concatenated == b"\xff\x00\x01" - - -class TestBaseByteListSSZ: - """SSZ interface methods and serialization round-trip.""" - - def test_is_fixed_size(self) -> None: - """BaseByteList subclasses are always variable-size.""" - assert ByteList16.is_fixed_size() is False - - def test_get_byte_length_raises(self) -> None: - """get_byte_length raises a descriptive error for variable-size types.""" - with pytest.raises(SSZTypeError) as exception_info: - ByteList16.get_byte_length() - assert ( - str(exception_info.value) - == "ByteList16: variable-size byte list has no fixed byte length" - ) - - @pytest.mark.parametrize( - "limit, data", - [ - (0, b""), - (1, b"\xaa"), - (5, b"\x00\x01\x02\x03\x04"), - (16, bytes(range(16))), - ], - ) - def test_encode_decode_roundtrip(self, limit: int, data: bytes) -> None: - """ByteList round-trips through encode_bytes, decode_bytes, and stream serialization.""" - - class TestByteList(BaseByteList): - LIMIT = limit - - byte_list = TestByteList(data=data) - assert byte_list.encode_bytes() == data - assert TestByteList.decode_bytes(data) == byte_list - - buffer = io.BytesIO() - bytes_written = byte_list.serialize(buffer) - assert bytes_written == len(data) - - buffer.seek(0) - deserialized = TestByteList.deserialize(buffer, len(data)) - assert deserialized == byte_list - - def test_deserialize_negative_scope_raises(self) -> None: - """deserialize rejects a negative scope.""" - buffer = io.BytesIO(b"") - with pytest.raises(SSZSerializationError) as exception_info: - ByteList16.deserialize(buffer, -1) - assert str(exception_info.value) == "ByteList16: negative scope" - - def test_deserialize_over_limit_raises(self) -> None: - """deserialize rejects a scope exceeding LIMIT.""" - buffer = io.BytesIO(b"\x00" * 6) - with pytest.raises(SSZValueError) as exception_info: - ByteList5.deserialize(buffer, 6) - assert str(exception_info.value) == "ByteList5 exceeds limit of 5, got 6" - - def test_deserialize_stream_truncation_raises(self) -> None: - """deserialize detects when the stream ends before delivering scope bytes.""" - buffer = io.BytesIO(b"\x00\x01") - with pytest.raises(SSZSerializationError) as exception_info: - ByteList16.deserialize(buffer, 3) - assert str(exception_info.value) == "ByteList16: expected 3 bytes, got 2" - - -class TestBaseByteListPydantic: - """Pydantic validation and JSON serialization for variable-length byte lists.""" - - def test_accepts_valid_input(self) -> None: - """Pydantic accepts construction with bytes within LIMIT.""" - raw_bytes = bytes.fromhex("000102030405060708090a0b0c0d0e0f") - model = ModelLists(payload=ByteList16(data=raw_bytes)) - assert isinstance(model.payload, ByteList16) - assert model.payload.encode_bytes() == raw_bytes - - def test_rejects_oversized_input(self) -> None: - """Pydantic rejects data exceeding LIMIT via SSZValueError.""" - with pytest.raises(SSZValueError): - ModelLists(payload=ByteList16(data=bytes(range(17)))) - - def test_json_serialization_to_hex(self) -> None: - """JSON-mode serialization renders the data field as a 0x-prefixed hex string.""" - raw_bytes = bytes.fromhex("0001020304") - model = ModelLists(payload=ByteList16(data=raw_bytes)) - dumped = model.model_dump(mode="json") - assert dumped["payload"]["data"] == "0x0001020304" - - -def test_zero_hash_constant() -> None: - """The module-level ZERO_HASH is a 32-byte zero-filled Bytes32 instance.""" - assert isinstance(ZERO_HASH, Bytes32) - assert bytes(ZERO_HASH) == b"\x00" * 32 - - -def test_json_dumpable_via_hex() -> None: - """Byte instances are JSON-dumpable when pre-encoded to hex strings.""" - hex_encoded_fields = { - "root": Bytes32(b"\x11" * 32).hex(), - "key": Bytes4(b"\x00\x01\x02\x03").hex(), - "payload": ByteList5(data=b"\x00\x01\x02").hex(), - } - assert json.loads(json.dumps(hex_encoded_fields)) == hex_encoded_fields - - -@given(raw_bytes=st.binary(min_size=32, max_size=32)) -def test_byte_vector_round_trip_random_bytes(raw_bytes: bytes) -> None: - """Any fixed-length byte pattern survives an encode and decode round trip.""" - instance = Bytes32(raw_bytes) - assert Bytes32.decode_bytes(instance.encode_bytes()) == instance - - -@given(raw_bytes=st.binary(max_size=16)) -def test_byte_list_round_trip_random_bytes(raw_bytes: bytes) -> None: - """Any byte pattern up to the limit, including empty, round-trips unchanged.""" - instance = ByteList16(data=raw_bytes) - assert ByteList16.decode_bytes(instance.encode_bytes()) == instance diff --git a/tests/spec/ssz/test_collections.py b/tests/spec/ssz/test_collections.py deleted file mode 100644 index 9bfd0f195..000000000 --- a/tests/spec/ssz/test_collections.py +++ /dev/null @@ -1,947 +0,0 @@ -"""Tests for the SSZVector and SSZList types.""" - -from typing import Any, cast - -import pytest -from hypothesis import given, strategies as st -from pydantic import BaseModel, ValidationError - -from lean_spec.spec.crypto.koalabear import Fp -from lean_spec.spec.ssz import Bytes32, Uint8, Uint16, Uint32 -from lean_spec.spec.ssz.boolean import Boolean -from lean_spec.spec.ssz.collections import SSZList, SSZVector -from lean_spec.spec.ssz.container import Container -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError, SSZValueError - -ValueOrValidationError = (SSZValueError, ValidationError) -TypeOrValidationError = (SSZTypeError, ValidationError) - - -class Uint16List4(SSZList[Uint16]): - """A list with up to 4 Uint16 values.""" - - LIMIT = 4 - - -class FixedContainer(Container): - """A simple fixed-size container for testing composite types in collections.""" - - a: Uint8 - b: Uint16 - - -class VariableContainer(Container): - """A variable-size container for testing composite types in collections.""" - - a: Uint8 - b: Uint16List4 - - -class Uint16Vector2(SSZVector[Uint16]): - """A vector of exactly 2 Uint16 values.""" - - LENGTH = 2 - - -class Uint8Vector4(SSZVector[Uint8]): - """A vector of exactly 4 Uint8 values.""" - - LENGTH = 4 - - -class Uint8Vector48(SSZVector[Uint8]): - """A vector of exactly 48 Uint8 values.""" - - LENGTH = 48 - - -class Uint8Vector96(SSZVector[Uint8]): - """A vector of exactly 96 Uint8 values.""" - - LENGTH = 96 - - -class FixedContainerVector2(SSZVector[FixedContainer]): - """A vector of exactly 2 FixedContainer values.""" - - LENGTH = 2 - - -class VariableContainerVector2(SSZVector[VariableContainer]): - """A vector of exactly 2 VariableContainer values.""" - - LENGTH = 2 - - -class Uint16List32(SSZList[Uint16]): - """A list with up to 32 Uint16 values.""" - - LIMIT = 32 - - -class Uint8List10(SSZList[Uint8]): - """A list with up to 10 Uint8 values.""" - - LIMIT = 10 - - -class Uint32List128(SSZList[Uint32]): - """A list with up to 128 Uint32 values.""" - - LIMIT = 128 - - -class Bytes32List32(SSZList[Bytes32]): - """A list with up to 32 Bytes32 values.""" - - LIMIT = 32 - - -class Bytes32List128(SSZList[Bytes32]): - """A list with up to 128 Bytes32 values.""" - - LIMIT = 128 - - -class VariableContainerList2(SSZList[VariableContainer]): - """A list with up to 2 VariableContainer values.""" - - LIMIT = 2 - - -class FixedContainerList2(SSZList[FixedContainer]): - """A list with up to 2 FixedContainer values.""" - - LIMIT = 2 - - -class Uint8Vector32(SSZVector[Uint8]): - """A vector of exactly 32 Uint8 values.""" - - LENGTH = 32 - - -class Uint16Vector32(SSZVector[Uint16]): - """A vector of exactly 32 Uint16 values.""" - - LENGTH = 32 - - -class Uint8Vector64(SSZVector[Uint8]): - """A vector of exactly 64 Uint8 values.""" - - LENGTH = 64 - - -class Uint8Vector2(SSZVector[Uint8]): - """A vector of exactly 2 Uint8 values.""" - - LENGTH = 2 - - -class FpVector8(SSZVector[Fp]): - """A vector of exactly 8 Fp values.""" - - LENGTH = 8 - - -class Uint8List32(SSZList[Uint8]): - """A list with up to 32 Uint8 values.""" - - LIMIT = 32 - - -class Uint8List64(SSZList[Uint8]): - """A list with up to 64 Uint8 values.""" - - LIMIT = 64 - - -class Uint8List4(SSZList[Uint8]): - """A list with up to 4 Uint8 values.""" - - LIMIT = 4 - - -class BooleanList4(SSZList[Boolean]): - """A list with up to 4 Boolean values.""" - - LIMIT = 4 - - -class FpList8(SSZList[Fp]): - """A list with up to 8 Fp values.""" - - LIMIT = 8 - - -class Uint8Vector2Model(BaseModel): - """Model for testing Pydantic validation of Uint8Vector2.""" - - value: Uint8Vector2 - - -class Uint8List4Model(BaseModel): - """Model for testing Pydantic validation of Uint8List4.""" - - value: Uint8List4 - - -class TestSSZVectorValidator: - """Tests for the SSZVector field validator and its rejection paths.""" - - def test_missing_element_type_and_length_rejected(self) -> None: - """A subclass without ELEMENT_TYPE or LENGTH cannot validate any input.""" - - class MissingBoth(SSZVector): - pass - - with pytest.raises(TypeOrValidationError) as exception_info: - MissingBoth(data=cast(Any, [1])) - assert str(exception_info.value) == "MissingBoth must define ELEMENT_TYPE and LENGTH" - - def test_missing_length_rejected(self) -> None: - """A subclass with ELEMENT_TYPE but no LENGTH cannot validate.""" - - class MissingLengthVector(SSZVector[Uint8]): - pass - - with pytest.raises(TypeOrValidationError) as exception_info: - MissingLengthVector(data=cast(Any, [1])) - assert ( - str(exception_info.value) == "MissingLengthVector must define ELEMENT_TYPE and LENGTH" - ) - - @pytest.mark.parametrize( - "bad_input, type_name", - [ - ("ab", "str"), - (b"ab", "bytes"), - (bytearray(b"ab"), "bytearray"), - ], - ) - def test_byte_like_inputs_rejected(self, bad_input: Any, type_name: str) -> None: - """Strings, bytes, and bytearrays never iterate as element collections.""" - with pytest.raises(TypeOrValidationError) as exception_info: - Uint8Vector2(data=bad_input) - assert ( - str(exception_info.value) - == f"Uint8Vector2: Expected iterable of Uint8, got {type_name}" - ) - - def test_non_iterable_scalar_rejected(self) -> None: - """Scalar inputs without an iterator interface raise an iterable error.""" - with pytest.raises(TypeOrValidationError) as exception_info: - Uint8Vector2(data=cast(Any, 42)) - assert str(exception_info.value) == "Uint8Vector2: Expected iterable, got int" - - def test_generator_input_coerced(self) -> None: - """A generator is materialized and each value is coerced to ELEMENT_TYPE.""" - instance = Uint8Vector4(data=cast(Any, (number for number in range(1, 5)))) - - assert tuple(instance) == (Uint8(1), Uint8(2), Uint8(3), Uint8(4)) - - def test_already_typed_elements_pass_through(self) -> None: - """Inputs already typed as ELEMENT_TYPE skip the coercion constructor.""" - original = [Uint8(1), Uint8(2), Uint8(3), Uint8(4)] - instance = Uint8Vector4(data=original) - - assert tuple(instance) == tuple(original) - - def test_raw_values_coerced_through_element_type(self) -> None: - """Raw Python ints are coerced through the declared element type.""" - instance = Uint8Vector4(data=cast(Any, [1, 2, 3, 4])) - - assert tuple(instance) == (Uint8(1), Uint8(2), Uint8(3), Uint8(4)) - - def test_element_coercion_failure_includes_chained_cause(self) -> None: - """A failed element coercion surfaces both the outer and inner error message.""" - with pytest.raises(TypeOrValidationError) as exception_info: - Uint8Vector4(data=cast(Any, [1, "bad", 3, 4])) - assert str(exception_info.value) == "Expected Uint8, got str: Expected int, got str" - - def test_too_few_elements_rejected(self) -> None: - """A vector requires exactly LENGTH elements and rejects shorter inputs.""" - with pytest.raises(ValueOrValidationError) as exception_info: - Uint8Vector4(data=cast(Any, [1, 2, 3])) - assert str(exception_info.value) == "Uint8Vector4 requires exactly 4 elements, got 3" - - def test_too_many_elements_rejected(self) -> None: - """A vector requires exactly LENGTH elements and rejects longer inputs.""" - with pytest.raises(ValueOrValidationError) as exception_info: - Uint8Vector4(data=cast(Any, [1, 2, 3, 4, 5])) - assert str(exception_info.value) == "Uint8Vector4 requires exactly 4 elements, got 5" - - -class TestSSZVectorClassMetadata: - """Tests for SSZVector class-level metadata and inference.""" - - def test_class_getitem_creates_specialized_type(self) -> None: - """Explicit subclasses keep distinct LENGTH and ELEMENT_TYPE bindings.""" - assert Uint8Vector32 is not Uint8Vector64 - assert Uint8Vector32 is not Uint16Vector32 - assert Uint8Vector32.LENGTH == 32 - assert Uint8Vector32.ELEMENT_TYPE is Uint8 - assert "Uint8Vector32" in repr(Uint8Vector32) - - def test_init_subclass_infers_element_type_from_generic(self) -> None: - """Generic subclasses copy the bracketed type into ELEMENT_TYPE.""" - - class LocalVector(SSZVector[Uint16]): - LENGTH = 1 - - assert LocalVector.ELEMENT_TYPE is Uint16 - - def test_init_subclass_preserves_explicit_element_type(self) -> None: - """An explicit ELEMENT_TYPE in the class body wins over generic inference.""" - - class LocalVector(SSZVector[Uint8]): - ELEMENT_TYPE = Uint16 - LENGTH = 1 - - assert LocalVector.ELEMENT_TYPE is Uint16 - - def test_instantiate_raw_type_raises_error(self) -> None: - """The raw SSZVector base cannot be instantiated as a Pydantic model.""" - with pytest.raises( - TypeError, - match=r"^BaseModel\.__init__\(\) takes 1 positional argument but 2 were given\Z", - ): - SSZVector([]) # type: ignore[misc] - - def test_fixed_size_vector_reports_fixed_size_true(self) -> None: - """A vector of fixed-size elements is itself fixed-size.""" - assert Uint8Vector4.is_fixed_size() is True - - def test_variable_size_vector_reports_fixed_size_false(self) -> None: - """A vector of variable-size elements is not fixed-size.""" - assert VariableContainerVector2.is_fixed_size() is False - - def test_fixed_size_vector_byte_length_matches_total(self) -> None: - """Byte length equals the element width times the element count.""" - assert Uint8Vector4.get_byte_length() == 4 - assert Uint16Vector2.get_byte_length() == 4 - assert FixedContainerVector2.get_byte_length() == 6 - - def test_variable_size_vector_has_no_fixed_byte_length(self) -> None: - """Variable-size vectors raise when asked for a fixed byte length.""" - with pytest.raises(SSZTypeError) as exception_info: - VariableContainerVector2.get_byte_length() - assert ( - str(exception_info.value) - == "VariableContainerVector2: variable-size vector has no fixed byte length" - ) - - -class TestSSZVectorAccessors: - """Tests for SSZVector accessor and immutability behavior.""" - - def test_instantiation_success(self) -> None: - """Building with the exact element count yields a sequence of typed values.""" - instance = Uint8Vector4(data=[Uint8(1), Uint8(2), Uint8(3), Uint8(4)]) - - assert len(instance) == 4 - assert list(instance) == [Uint8(1), Uint8(2), Uint8(3), Uint8(4)] - - def test_integer_index_returns_typed_element(self) -> None: - """Positive integer indexing returns the corresponding typed element.""" - instance = Uint8Vector4(data=[Uint8(10), Uint8(20), Uint8(30), Uint8(40)]) - - assert instance[0] == Uint8(10) - assert instance[2] == Uint8(30) - - def test_negative_index_returns_typed_element(self) -> None: - """Negative integer indexing addresses elements from the end of the sequence.""" - instance = Uint8Vector4(data=[Uint8(10), Uint8(20), Uint8(30), Uint8(40)]) - - assert instance[-1] == Uint8(40) - assert instance[-4] == Uint8(10) - - def test_slice_returns_sequence(self) -> None: - """Slicing returns the underlying tuple slice of typed elements.""" - instance = Uint8Vector4(data=[Uint8(1), Uint8(2), Uint8(3), Uint8(4)]) - - assert instance[1:3] == (Uint8(2), Uint8(3)) - - def test_elements_returns_mutable_copy(self) -> None: - """The elements property exposes a mutable list copy of the data.""" - instance = Uint8Vector4(data=[Uint8(1), Uint8(2), Uint8(3), Uint8(4)]) - - copy = instance.elements - copy.append(Uint8(9)) - - assert copy == [Uint8(1), Uint8(2), Uint8(3), Uint8(4), Uint8(9)] - assert list(instance) == [Uint8(1), Uint8(2), Uint8(3), Uint8(4)] - - def test_vector_is_immutable(self) -> None: - """Item assignment raises because the underlying model is frozen.""" - instance = Uint8Vector2(data=[Uint8(1), Uint8(2)]) - - with pytest.raises(TypeError): - instance[0] = 3 # type: ignore[index] - - def test_pydantic_dict_input_coerces_to_vector(self) -> None: - """Pydantic coerces a dict payload into an SSZVector with typed elements.""" - instance = Uint8Vector2Model(value=cast(Any, {"data": [10, 20]})) - - assert instance.value == Uint8Vector2(data=[Uint8(10), Uint8(20)]) - - def test_pydantic_dict_input_rejects_wrong_length(self) -> None: - """A dict payload with the wrong element count surfaces the length error.""" - with pytest.raises(ValueOrValidationError) as exception_info: - Uint8Vector2Model(value=cast(Any, {"data": [10]})) - assert str(exception_info.value) == "Uint8Vector2 requires exactly 2 elements, got 1" - - -class TestSSZListValidator: - """Tests for the SSZList field validator and its rejection paths.""" - - def test_missing_element_type_and_limit_rejected(self) -> None: - """A subclass without ELEMENT_TYPE or LIMIT cannot validate any input.""" - - class MissingBoth(SSZList): - pass - - with pytest.raises(TypeOrValidationError) as exception_info: - MissingBoth(data=cast(Any, [1])) - assert str(exception_info.value) == "MissingBoth must define ELEMENT_TYPE and LIMIT" - - def test_missing_limit_rejected(self) -> None: - """A subclass with ELEMENT_TYPE but no LIMIT cannot validate.""" - - class MissingLimitList(SSZList[Uint8]): - pass - - with pytest.raises(TypeOrValidationError) as exception_info: - MissingLimitList(data=cast(Any, [1])) - assert str(exception_info.value) == "MissingLimitList must define ELEMENT_TYPE and LIMIT" - - def test_raw_base_class_rejected(self) -> None: - """Instantiating the raw SSZList base surfaces the metadata-missing error.""" - with pytest.raises(SSZTypeError) as exception_info: - SSZList(data=[]) - assert str(exception_info.value) == "SSZList must define ELEMENT_TYPE and LIMIT" - - @pytest.mark.parametrize( - "bad_input, type_name", - [ - ("ab", "str"), - (b"ab", "bytes"), - (bytearray(b"ab"), "bytearray"), - ], - ) - def test_byte_like_inputs_rejected(self, bad_input: Any, type_name: str) -> None: - """Strings, bytes, and bytearrays never iterate as element collections.""" - with pytest.raises(TypeOrValidationError) as exception_info: - Uint8List4(data=bad_input) - assert ( - str(exception_info.value) == f"Uint8List4: Expected iterable of Uint8, got {type_name}" - ) - - def test_non_iterable_scalar_rejected(self) -> None: - """Scalar inputs without an iterator interface raise an iterable error.""" - with pytest.raises(TypeOrValidationError) as exception_info: - Uint8List4(data=cast(Any, 5)) - assert str(exception_info.value) == "Uint8List4: Expected iterable, got int" - - def test_generator_input_coerced(self) -> None: - """A generator is materialized and each value is coerced to ELEMENT_TYPE.""" - instance = Uint8List4(data=cast(Any, (number for number in range(3)))) - - assert list(instance) == [Uint8(0), Uint8(1), Uint8(2)] - - def test_already_typed_elements_pass_through(self) -> None: - """Inputs already typed as ELEMENT_TYPE skip the coercion constructor.""" - instance = Uint8List4(data=[Uint8(1), Uint8(2)]) - - assert list(instance) == [Uint8(1), Uint8(2)] - - def test_raw_values_coerced_through_element_type(self) -> None: - """Raw Python ints are coerced through the declared element type.""" - instance = Uint8List4(data=cast(Any, [1, 2, 3])) - - assert list(instance) == [Uint8(1), Uint8(2), Uint8(3)] - - def test_element_coercion_failure_includes_chained_cause(self) -> None: - """A failed element coercion surfaces both the outer and inner error message.""" - with pytest.raises(TypeOrValidationError) as exception_info: - Uint8List4(data=cast(Any, [1, "bad"])) - assert str(exception_info.value) == "Expected Uint8, got str: Expected int, got str" - - def test_empty_list_allowed(self) -> None: - """A list with zero elements is always valid, regardless of LIMIT.""" - instance = Uint8List4(data=[]) - - assert list(instance) == [] - assert len(instance) == 0 - - def test_construction_at_limit_allowed(self) -> None: - """A list with exactly LIMIT elements is valid.""" - instance = Uint8List4(data=cast(Any, [1, 2, 3, 4])) - - assert list(instance) == [Uint8(1), Uint8(2), Uint8(3), Uint8(4)] - - def test_over_limit_rejected(self) -> None: - """A list with more than LIMIT elements raises the exceeds-limit error.""" - with pytest.raises(ValueOrValidationError) as exception_info: - Uint8List4(data=cast(Any, [1, 2, 3, 4, 5])) - assert str(exception_info.value) == "Uint8List4 exceeds limit of 4, got 5" - - def test_over_limit_rejected_for_boolean_list(self) -> None: - """The same exceeds-limit error fires for a list of booleans.""" - with pytest.raises(ValueOrValidationError) as exception_info: - BooleanList4(data=[Boolean(True)] * 5) - assert str(exception_info.value) == "BooleanList4 exceeds limit of 4, got 5" - - -class TestSSZListClassMetadata: - """Tests for SSZList class-level metadata and inference.""" - - def test_class_getitem_creates_specialized_type(self) -> None: - """Explicit subclasses keep distinct LIMIT and ELEMENT_TYPE bindings.""" - assert Uint8List32 is not Uint8List64 - assert Uint8List32 is not Uint16List32 - assert Uint8List32.LIMIT == 32 - assert Uint8List32.ELEMENT_TYPE is Uint8 - assert "Uint8List32" in repr(Uint8List32) - - def test_init_subclass_infers_element_type_from_generic(self) -> None: - """Generic subclasses copy the bracketed type into ELEMENT_TYPE.""" - - class LocalList(SSZList[Uint16]): - LIMIT = 2 - - assert LocalList.ELEMENT_TYPE is Uint16 - - def test_list_is_never_fixed_size(self) -> None: - """A list never collapses to a fixed-size encoding.""" - assert Uint8List4.is_fixed_size() is False - assert VariableContainerList2.is_fixed_size() is False - - def test_get_byte_length_always_raises(self) -> None: - """A list type has no fixed byte length even for fixed-size elements.""" - with pytest.raises(SSZTypeError) as exception_info: - Uint8List4.get_byte_length() - assert ( - str(exception_info.value) == "Uint8List4: variable-size list has no fixed byte length" - ) - - def test_get_byte_length_raises_for_variable_element_list(self) -> None: - """The same error fires for lists whose elements are variable-size.""" - with pytest.raises(SSZTypeError) as exception_info: - VariableContainerList2.get_byte_length() - assert ( - str(exception_info.value) - == "VariableContainerList2: variable-size list has no fixed byte length" - ) - - -class TestSSZListAccessors: - """Tests for SSZList accessor and concatenation behavior.""" - - def test_integer_index_returns_typed_element(self) -> None: - """Positive integer indexing returns the corresponding typed element.""" - instance = Uint8List4(data=[Uint8(10), Uint8(20), Uint8(30)]) - - assert instance[0] == Uint8(10) - assert instance[2] == Uint8(30) - - def test_negative_index_returns_typed_element(self) -> None: - """Negative integer indexing addresses elements from the end of the sequence.""" - instance = Uint8List4(data=[Uint8(10), Uint8(20), Uint8(30)]) - - assert instance[-1] == Uint8(30) - assert instance[-3] == Uint8(10) - - def test_slice_returns_sequence(self) -> None: - """Slicing returns the underlying tuple slice of typed elements.""" - instance = Uint8List4(data=[Uint8(1), Uint8(2), Uint8(3)]) - - assert instance[1:3] == (Uint8(2), Uint8(3)) - - def test_elements_returns_mutable_copy(self) -> None: - """The elements property exposes a mutable list copy of the data.""" - instance = Uint8List4(data=[Uint8(1), Uint8(2), Uint8(3)]) - - copy = instance.elements - copy.append(Uint8(9)) - - assert copy == [Uint8(1), Uint8(2), Uint8(3), Uint8(9)] - assert list(instance) == [Uint8(1), Uint8(2), Uint8(3)] - - def test_pydantic_dict_input_coerces_to_list(self) -> None: - """Pydantic coerces a list payload into an SSZList with typed elements.""" - instance = Uint8List4Model(value=Uint8List4(data=[Uint8(10), Uint8(20)])) - - assert instance.value == Uint8List4(data=[Uint8(10), Uint8(20)]) - - def test_add_with_sszlist(self) -> None: - """Concatenating two SSZLists yields a fresh list of the same type.""" - concatenated = Uint8List10(data=[Uint8(1), Uint8(2)]) + Uint8List10( - data=[Uint8(3), Uint8(4)] - ) - - assert concatenated == Uint8List10(data=[Uint8(1), Uint8(2), Uint8(3), Uint8(4)]) - assert isinstance(concatenated, Uint8List10) - - def test_add_with_plain_list(self) -> None: - """Concatenating with a plain list coerces the right-hand values.""" - concatenated = Uint8List10(data=[Uint8(1), Uint8(2), Uint8(3)]) + [4, 5] - - assert concatenated == Uint8List10(data=[Uint8(1), Uint8(2), Uint8(3), Uint8(4), Uint8(5)]) - - def test_add_with_tuple(self) -> None: - """Concatenating with a tuple coerces the right-hand values.""" - concatenated = Uint8List10(data=[Uint8(1), Uint8(2)]) + (3, 4) - - assert concatenated == Uint8List10(data=[Uint8(1), Uint8(2), Uint8(3), Uint8(4)]) - - def test_add_empty_to_empty(self) -> None: - """Concatenating two empty lists yields an empty list of the same type.""" - concatenated = Uint8List10(data=[]) + Uint8List10(data=[]) - - assert concatenated == Uint8List10(data=[]) - - def test_add_empty_to_non_empty(self) -> None: - """Concatenating an empty list to a populated one preserves the populated list.""" - populated = Uint8List10(data=[Uint8(1), Uint8(2)]) - concatenated = Uint8List10(data=[]) + populated - - assert concatenated == populated - - def test_add_non_empty_to_empty(self) -> None: - """Concatenating a populated list to an empty one preserves the populated list.""" - populated = Uint8List10(data=[Uint8(1), Uint8(2)]) - concatenated = populated + Uint8List10(data=[]) - - assert concatenated == populated - - def test_add_unsupported_type_returns_not_implemented(self) -> None: - """Unsupported operands return NotImplemented from the add hook.""" - instance = Uint8List10(data=[Uint8(1), Uint8(2)]) - - assert instance.__add__(object()) is NotImplemented - - def test_add_exceeding_limit_raises_error(self) -> None: - """Concatenation that overflows LIMIT raises the exceeds-limit error.""" - base = Uint8List4(data=[Uint8(1), Uint8(2), Uint8(3)]) - with pytest.raises(ValueOrValidationError) as exception_info: - base + [4, 5] - assert str(exception_info.value) == "Uint8List4 exceeds limit of 4, got 5" - - -class TestSSZVectorSerialization: - """Tests SSZ serialization and deserialization for SSZVector.""" - - @pytest.mark.parametrize( - "vector_type, elements, expected_hex", - [ - (Uint16Vector2, (0x4567, 0x0123), "67452301"), - (Uint8Vector4, (1, 2, 3, 4), "01020304"), - ( - Uint8Vector48, - tuple(range(48)), - "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" - "202122232425262728292a2b2c2d2e2f", - ), - ( - Uint8Vector96, - tuple( - 1 if i == 0 else 2 if i == 32 else 3 if i == 64 else 0xFF if i == 95 else 0 - for i in range(96) - ), - "0100000000000000000000000000000000000000000000000000000000000000" - "0200000000000000000000000000000000000000000000000000000000000000" - "03000000000000000000000000000000000000000000000000000000000000ff", - ), - ( - FixedContainerVector2, - ( - FixedContainer(a=Uint8(1), b=Uint16(2)), - FixedContainer(a=Uint8(3), b=Uint16(4)), - ), - "010200030400", - ), - ( - FpVector8, - (10, 20, 30, 40, 50, 60, 70, 80), - "0a000000140000001e00000028000000320000003c0000004600000050000000", - ), - ], - ) - def test_fixed_size_element_vector_roundtrip( - self, - vector_type: type[SSZVector], - elements: tuple[Any, ...], - expected_hex: str, - ) -> None: - """Fixed-size vectors encode to a known hex layout and round-trip back.""" - instance = vector_type(data=elements) - encoded = instance.encode_bytes() - - assert encoded.hex() == expected_hex - assert vector_type.decode_bytes(encoded) == instance - - def test_variable_size_element_vector_roundtrip(self) -> None: - """Variable-size vectors emit the offset table followed by buffered bodies.""" - val1 = VariableContainer(a=Uint8(1), b=Uint16List4(data=[Uint16(10), Uint16(20)])) - val2 = VariableContainer(a=Uint8(2), b=Uint16List4(data=[Uint16(30)])) - instance = VariableContainerVector2(data=[val1, val2]) - - expected_hex = "080000001100000001050000000a00140002050000001e00" - encoded = instance.encode_bytes() - - assert encoded.hex() == expected_hex - assert VariableContainerVector2.decode_bytes(encoded) == instance - - def test_fixed_size_vector_rejects_scope_too_small(self) -> None: - """A fixed-size vector rejects payloads shorter than its byte budget.""" - with pytest.raises(SSZSerializationError) as exception_info: - Uint8Vector4.decode_bytes(b"\x00\x01\x02") - assert str(exception_info.value) == "Uint8Vector4: expected 4 bytes, got 3" - - def test_fixed_size_vector_rejects_scope_too_large(self) -> None: - """A fixed-size vector rejects payloads larger than its byte budget.""" - with pytest.raises(SSZSerializationError) as exception_info: - Uint8Vector4.decode_bytes(b"\x00\x01\x02\x03\x04") - assert str(exception_info.value) == "Uint8Vector4: expected 4 bytes, got 5" - - def test_variable_size_vector_rejects_scope_below_offset_table(self) -> None: - """A scope smaller than the offset table cannot describe any layout.""" - with pytest.raises(SSZSerializationError) as exception_info: - VariableContainerVector2.decode_bytes(b"\x00\x00\x00") - assert ( - str(exception_info.value) - == "VariableContainerVector2: scope 3 too small, expected at least 8" - ) - - def test_variable_size_vector_rejects_invalid_first_offset(self) -> None: - """The first offset must point past the offset table.""" - with pytest.raises(SSZSerializationError) as exception_info: - VariableContainerVector2.decode_bytes(b"\x04\x00\x00\x00\x08\x00\x00\x00") - assert str(exception_info.value) == "VariableContainerVector2: invalid offset 4, expected 8" - - def test_variable_size_vector_rejects_non_monotonic_offsets(self) -> None: - """A later offset smaller than an earlier one means a body would have negative width.""" - # Layout: - # - # offsets[0] = 8 (table-end, valid first offset) - # offsets[1] = 6 (decreasing, triggers the monotonic check) - encoded_bytes = b"\x08\x00\x00\x00\x06\x00\x00\x00" - with pytest.raises(SSZSerializationError) as exception_info: - VariableContainerVector2.decode_bytes(encoded_bytes) - assert ( - str(exception_info.value) - == "VariableContainerVector2: offsets not monotonically increasing: 8 -> 6" - ) - - def test_variable_size_vector_rejects_final_offset_overflow(self) -> None: - """A final offset that exceeds the scope triggers the monotonic check first.""" - # Layout: - # - # offsets[0] = 8 (table-end, valid first offset) - # offsets[1] = 100 (past scope of 20, but also greater than next, scope=20) - # - # Pairwise iteration appends scope as the final boundary, so the 100 -> 20 - # transition trips the monotonic check before the final-offset-exceeds-scope check. - encoded_bytes = b"\x08\x00\x00\x00\x64\x00\x00\x00" + b"\x00" * 12 - with pytest.raises(SSZSerializationError) as exception_info: - VariableContainerVector2.decode_bytes(encoded_bytes) - assert ( - str(exception_info.value) - == "VariableContainerVector2: offsets not monotonically increasing: 100 -> 20" - ) - - -class TestSSZListSerialization: - """Tests SSZ serialization and deserialization for SSZList.""" - - @pytest.mark.parametrize( - "list_type, elements, expected_hex", - [ - (Uint16List32, (0xAABB, 0xC0AD, 0xEEFF), "bbaaadc0ffee"), - (Uint8List10, (), ""), - (Uint8List10, (0, 1, 2, 3, 4, 5, 6), "00010203040506"), - (Uint32List128, (0xAABB, 0xC0AD, 0xEEFF), "bbaa0000adc00000ffee0000"), - ( - Bytes32List32, - ( - b"\xbb\xaa" + b"\x00" * 30, - b"\xad\xc0" + b"\x00" * 30, - b"\xff\xee" + b"\x00" * 30, - ), - ( - "bbaa000000000000000000000000000000000000000000000000000000000000" - "adc0000000000000000000000000000000000000000000000000000000000000" - "ffee000000000000000000000000000000000000000000000000000000000000" - ), - ), - ( - Bytes32List128, - tuple(i.to_bytes(32, "little") for i in range(1, 20)), - "".join(i.to_bytes(32, "little").hex() for i in range(1, 20)), - ), - ( - FpList8, - (10, 20, 30), - "0a000000140000001e000000", - ), - ], - ) - def test_fixed_size_element_list_roundtrip( - self, - list_type: type[SSZList], - elements: tuple[Any, ...], - expected_hex: str, - ) -> None: - """Fixed-size lists pack bodies back-to-back without separators.""" - instance = list_type(data=elements) - encoded = instance.encode_bytes() - - assert encoded.hex() == expected_hex - assert list_type.decode_bytes(encoded) == instance - - def test_variable_size_element_list_roundtrip(self) -> None: - """Variable-size lists emit a runtime-sized offset table before the bodies.""" - val1 = VariableContainer(a=Uint8(1), b=Uint16List4(data=[Uint16(10)])) - val2 = VariableContainer(a=Uint8(2), b=Uint16List4(data=[Uint16(30), Uint16(40)])) - instance = VariableContainerList2(data=[val1, val2]) - - expected_hex = "080000000f00000001050000000a0002050000001e002800" - encoded = instance.encode_bytes() - - assert encoded.hex() == expected_hex - assert VariableContainerList2.decode_bytes(encoded) == instance - - def test_empty_scope_decodes_to_empty_list(self) -> None: - """An empty payload always decodes to an empty list.""" - assert VariableContainerList2.decode_bytes(b"") == VariableContainerList2(data=[]) - - def test_fixed_size_list_rejects_scope_not_divisible_by_element_size(self) -> None: - """A fixed-size list rejects payloads whose length is not a multiple of the stride.""" - with pytest.raises(SSZSerializationError) as exception_info: - Uint16List4.decode_bytes(b"\x01") - assert str(exception_info.value) == "Uint16List4: scope 1 not divisible by element size 2" - - def test_fixed_size_list_rejects_count_beyond_limit(self) -> None: - """A fixed-size list rejects payloads that decode to more than LIMIT elements.""" - with pytest.raises(SSZValueError) as exception_info: - Uint8List4.decode_bytes(b"\x00\x01\x02\x03\x04") - assert str(exception_info.value) == "Uint8List4 exceeds limit of 4, got 5" - - def test_variable_size_list_rejects_scope_below_offset_word(self) -> None: - """A variable-size list requires at least one offset word in the payload.""" - with pytest.raises(SSZSerializationError) as exception_info: - VariableContainerList2.decode_bytes(b"\x00\x00\x00") - assert ( - str(exception_info.value) - == "VariableContainerList2: scope 3 too small for variable-size list" - ) - - def test_variable_size_list_rejects_first_offset_past_scope(self) -> None: - """A first offset larger than the available scope is invalid.""" - with pytest.raises(SSZSerializationError) as exception_info: - VariableContainerList2.decode_bytes(b"\x64\x00\x00\x00") - assert str(exception_info.value) == "VariableContainerList2: invalid offset 100" - - def test_variable_size_list_rejects_misaligned_first_offset(self) -> None: - """A first offset that is not a multiple of the offset width is invalid.""" - with pytest.raises(SSZSerializationError) as exception_info: - VariableContainerList2.decode_bytes(b"\x05\x00\x00\x00\x00\x00\x00\x00") - assert str(exception_info.value) == "VariableContainerList2: invalid offset 5" - - def test_variable_size_list_rejects_zero_first_offset(self) -> None: - """A zero first offset is contradictory and rejected before building the boundary list.""" - with pytest.raises(SSZSerializationError) as exception_info: - VariableContainerList2.decode_bytes(bytes.fromhex("00000000aabbccdd")) - assert str(exception_info.value) == "VariableContainerList2: invalid offset 0" - - def test_variable_size_list_rejects_count_beyond_limit(self) -> None: - """A first offset that implies more than LIMIT elements is rejected.""" - # Layout: - # - # first_offset = 12 (count = 12 / 4 = 3, above LIMIT=2) - encoded_bytes = b"\x0c\x00\x00\x00" + b"\x00" * 8 - with pytest.raises(SSZValueError) as exception_info: - VariableContainerList2.decode_bytes(encoded_bytes) - assert str(exception_info.value) == "VariableContainerList2 exceeds limit of 2, got 3" - - def test_variable_size_list_rejects_non_monotonic_offsets(self) -> None: - """A later offset smaller than an earlier one means a body would have negative width.""" - # Layout: - # - # first_offset = 8 (count = 2, table-end) - # offsets[1] = 6 (decreasing, triggers the monotonic check) - encoded_bytes = b"\x08\x00\x00\x00\x06\x00\x00\x00" + b"\x00" * 12 - with pytest.raises(SSZSerializationError) as exception_info: - VariableContainerList2.decode_bytes(encoded_bytes) - assert ( - str(exception_info.value) - == "VariableContainerList2: offsets not monotonically increasing: 8 -> 6" - ) - - def test_variable_size_list_rejects_final_offset_overflow(self) -> None: - """An interior offset past the payload's end triggers the monotonic check.""" - encoded_bytes = b"\x08\x00\x00\x00\x64\x00\x00\x00" + b"\x00" * 12 - with pytest.raises(SSZSerializationError) as exception_info: - VariableContainerList2.decode_bytes(encoded_bytes) - assert ( - str(exception_info.value) - == "VariableContainerList2: offsets not monotonically increasing: 100 -> 20" - ) - - def test_variable_size_list_single_element_decodes(self) -> None: - """A single-element list reads no further offsets after the first.""" - element = VariableContainer(a=Uint8(1), b=Uint16List4(data=[Uint16(10)])) - encoded = VariableContainerList2(data=[element]).encode_bytes() - - assert VariableContainerList2.decode_bytes(encoded) == VariableContainerList2( - data=[element] - ) - - -class TestJsonSerialization: - """Tests for the JSON field serializer on SSZ sequences.""" - - def test_byte_array_elements_render_as_hex_strings(self) -> None: - """Byte-array leaves render as 0x-prefixed hex strings in JSON output.""" - instance = Bytes32List32(data=[Bytes32.zero()]) - - assert instance.model_dump(mode="json") == {"data": ["0x" + ("00" * 32)]} - - def test_integer_elements_render_as_plain_ints(self) -> None: - """Field-element leaves flatten to plain Python ints in JSON output.""" - instance = FpVector8(data=[Fp(1), Fp(2), Fp(3), Fp(4), Fp(5), Fp(6), Fp(7), Fp(8)]) - - assert instance.model_dump(mode="json") == {"data": [1, 2, 3, 4, 5, 6, 7, 8]} - - def test_boolean_elements_render_as_true_false(self) -> None: - """Booleans are excluded from the int branch and stay as true/false.""" - instance = BooleanList4(data=[Boolean(True), Boolean(False), Boolean(True)]) - - assert instance.model_dump(mode="json") == {"data": [True, False, True]} - - def test_container_elements_pass_through_to_pydantic(self) -> None: - """Container elements fall through the else branch and recurse via Pydantic.""" - instance = FixedContainerList2( - data=[ - FixedContainer(a=Uint8(1), b=Uint16(2)), - FixedContainer(a=Uint8(3), b=Uint16(4)), - ] - ) - - assert instance.model_dump(mode="json") == {"data": [{"a": 1, "b": 2}, {"a": 3, "b": 4}]} - - -@given(values=st.lists(st.integers(min_value=0, max_value=2**16 - 1), max_size=4)) -def test_list_round_trip_random_values(values: list[int]) -> None: - """Any element sequence up to the limit, including empty, round-trips unchanged.""" - instance = Uint16List4(data=[Uint16(value) for value in values]) - assert Uint16List4.decode_bytes(instance.encode_bytes()) == instance - - -@given(values=st.lists(st.integers(min_value=0, max_value=255), min_size=4, max_size=4)) -def test_vector_round_trip_random_values(values: list[int]) -> None: - """Any fixed-length element sequence round-trips unchanged.""" - instance = Uint8Vector4(data=[Uint8(value) for value in values]) - assert Uint8Vector4.decode_bytes(instance.encode_bytes()) == instance diff --git a/tests/spec/ssz/test_container.py b/tests/spec/ssz/test_container.py deleted file mode 100644 index ab4fbb641..000000000 --- a/tests/spec/ssz/test_container.py +++ /dev/null @@ -1,469 +0,0 @@ -"""Tests for the SSZ Container base class.""" - -import io - -import pytest -from hypothesis import given, strategies as st -from pydantic import ValidationError - -from lean_spec.spec.ssz.collections import SSZList -from lean_spec.spec.ssz.container import Container -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError -from lean_spec.spec.ssz.uint import Uint8, Uint16, Uint32, Uint64 - - -class Uint16List4(SSZList[Uint16]): - """A list with up to 4 Uint16 values for variable-field testing.""" - - LIMIT = 4 - - -class TwoUint64(Container): - """Two fixed-size Uint64 fields, total width 16 bytes.""" - - a: Uint64 - b: Uint64 - - -class TwoVar(Container): - """Two variable-size list fields, total width is dynamic.""" - - a: Uint16List4 - b: Uint16List4 - - -class Mixed(Container): - """Interleaved fixed and variable fields covering the canonical mixed shape.""" - - a: Uint64 - b: Uint16List4 - c: Uint32 - d: Uint16List4 - - -class OneVar(Container): - """Single variable-size field, exercises the single-offset branch.""" - - a: Uint16List4 - - -class InnerFixed(Container): - """Inner fixed-size container nested inside another container.""" - - x: Uint64 - y: Uint64 - - -class OuterFixedNested(Container): - """Outer fixed-size container that holds a fixed-size container as a field.""" - - z: Uint64 - inner: InnerFixed - - -class InnerVar(Container): - """Inner variable-size container with one variable field.""" - - a: Uint64 - b: Uint16List4 - - -class OuterVarNested(Container): - """Outer container that holds a variable-size container as a field.""" - - head: Uint64 - inner: InnerVar - - -class Attestation(Container): - """Parent container with a fixed slot and a variable data list.""" - - slot: Uint64 - data: Uint16List4 - - -class SignedAttestation(Attestation): - """Subclass appending a signature field after the parent fields.""" - - signature: Uint64 - - -class EmptyContainer(Container): - """Zero-field container, exercises the all-fixed sum over an empty iterator.""" - - -class OneByte(Container): - """Smallest non-empty fixed container, used for hex helpers.""" - - a: Uint8 - - -class TestFixedContainer: - """Fixed-size container metadata, encoding, and roundtrip behavior.""" - - def test_is_fixed_size_true(self) -> None: - """A container of only fixed-size fields reports as fixed-size.""" - assert TwoUint64.is_fixed_size() is True - - def test_get_byte_length_sums_field_widths(self) -> None: - """The fixed byte width is the sum of each field's byte width.""" - assert TwoUint64.get_byte_length() == 16 - - def test_serialize_writes_little_endian_fields(self) -> None: - """Encoding concatenates each field's little-endian bytes in order.""" - encoded = TwoUint64(a=Uint64(1), b=Uint64(2)).encode_bytes() - assert encoded == b"\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00" - - @pytest.mark.parametrize( - ("a", "b"), - [ - pytest.param(0, 0, id="edge_zero"), - pytest.param(1, 2, id="small"), - pytest.param(0xDEADBEEF, 0xCAFEBABE, id="medium"), - pytest.param(2**64 - 1, 2**64 - 1, id="large"), - ], - ) - def test_roundtrip_preserves_value(self, a: int, b: int) -> None: - """Encoding then decoding recovers the original fixed container exactly.""" - original = TwoUint64(a=Uint64(a), b=Uint64(b)) - assert TwoUint64.decode_bytes(original.encode_bytes()) == original - - def test_empty_container_has_zero_byte_length(self) -> None: - """A container with no fields has a fixed byte length of zero.""" - assert EmptyContainer.is_fixed_size() is True - assert EmptyContainer.get_byte_length() == 0 - assert EmptyContainer().encode_bytes() == b"" - - -class TestVariableContainer: - """All-variable container shape and metadata.""" - - def test_is_fixed_size_false(self) -> None: - """A container with only variable-size fields reports as not fixed-size.""" - assert TwoVar.is_fixed_size() is False - - def test_get_byte_length_raises(self) -> None: - """A variable-size container has no fixed byte length and must raise.""" - with pytest.raises(SSZTypeError) as exc_info: - TwoVar.get_byte_length() - assert exc_info.value.args[0] == "TwoVar: variable-size container has no fixed byte length" - - def test_all_variable_roundtrip(self) -> None: - """A container of two variable lists roundtrips through encode then decode.""" - original = TwoVar( - a=Uint16List4(data=[Uint16(0x1234), Uint16(0x5678)]), - b=Uint16List4(data=[Uint16(0x9ABC)]), - ) - # Fixed-part width is 8 bytes for two Uint32 offsets. - # First offset is 8, second offset is 12 because the first payload spans 4 bytes. - expected_encoding = bytes.fromhex("080000000c00000034127856bc9a") - assert original.encode_bytes() == expected_encoding - assert TwoVar.decode_bytes(expected_encoding) == original - - -class TestOneVariableField: - """Edge case where the variable-field list contains exactly one entry.""" - - def test_one_variable_field_roundtrip(self) -> None: - """A container with a single variable field encodes one offset and the payload.""" - original = OneVar(a=Uint16List4(data=[Uint16(0x1234)])) - # Fixed part is one offset of 4 bytes pointing to 4, then the payload. - assert original.encode_bytes() == bytes.fromhex("040000003412") - assert OneVar.decode_bytes(bytes.fromhex("040000003412")) == original - - def test_one_variable_field_with_empty_payload(self) -> None: - """An empty variable field exercises the start equals end span branch.""" - original = OneVar(a=Uint16List4(data=[])) - # The offset still points to byte 4, and the payload is zero bytes long. - encoded = original.encode_bytes() - assert encoded == bytes.fromhex("04000000") - assert OneVar.decode_bytes(encoded) == original - - -class TestMixedContainer: - """Interleaved fixed and variable fields, the canonical wire layout.""" - - def test_mixed_is_variable(self) -> None: - """Any variable field forces the whole container to be variable-size.""" - assert Mixed.is_fixed_size() is False - - def test_mixed_get_byte_length_raises(self) -> None: - """The mixed container has no fixed byte length and must raise.""" - with pytest.raises(SSZTypeError) as exc_info: - Mixed.get_byte_length() - assert exc_info.value.args[0] == "Mixed: variable-size container has no fixed byte length" - - def test_mixed_wire_layout(self) -> None: - """The fixed slots and offsets land before the tail payloads in field order.""" - # Fixture state: - # a (Uint64) = 0xAABBCCDD -> ddccbbaa00000000 (8 bytes) - # b offset = 20 -> 14000000 (4 bytes) - # c (Uint32) = 0xEEFF -> ffee0000 (4 bytes) - # d offset = 24 -> 18000000 (4 bytes) - # b payload = [1, 2] Uint16 -> 01000200 (4 bytes) - # d payload = [3] Uint16 -> 0300 (2 bytes) - original = Mixed( - a=Uint64(0xAABBCCDD), - b=Uint16List4(data=[Uint16(1), Uint16(2)]), - c=Uint32(0xEEFF), - d=Uint16List4(data=[Uint16(3)]), - ) - expected_encoding = bytes.fromhex("ddccbbaa0000000014000000ffee000018000000010002000300") - assert original.encode_bytes() == expected_encoding - assert Mixed.decode_bytes(expected_encoding) == original - - -class TestNestedContainer: - """Containers nested as fields of other containers.""" - - def test_fixed_inside_fixed_is_fixed(self) -> None: - """A fixed container holding another fixed container stays fixed-size.""" - assert OuterFixedNested.is_fixed_size() is True - # 8 bytes for z plus 16 bytes for the inner pair. - assert OuterFixedNested.get_byte_length() == 24 - - def test_fixed_inside_fixed_roundtrip(self) -> None: - """Encoding lays out the outer field then the inner fields back to back.""" - original = OuterFixedNested(z=Uint64(7), inner=InnerFixed(x=Uint64(1), y=Uint64(2))) - encoded = original.encode_bytes() - assert encoded == bytes.fromhex("070000000000000001000000000000000200000000000000") - assert OuterFixedNested.decode_bytes(encoded) == original - - def test_variable_inside_outer_is_variable(self) -> None: - """A variable inner container forces the outer to be variable-size.""" - assert OuterVarNested.is_fixed_size() is False - - def test_variable_inside_outer_roundtrip(self) -> None: - """The inner variable container is treated as a single variable field on the outer.""" - # Fixture state: - # head = 99 -> 6300000000000000 (8 bytes fixed) - # inner offset = 12 -> 0c000000 (4 bytes) - # inner payload begins at byte 12: - # inner.a = 7 -> 0700000000000000 (8 bytes) - # inner.b offset = 12 -> 0c000000 (4 bytes) - # inner.b payload [1,2] -> 01000200 (4 bytes) - original = OuterVarNested( - head=Uint64(99), - inner=InnerVar(a=Uint64(7), b=Uint16List4(data=[Uint16(1), Uint16(2)])), - ) - expected_encoding = bytes.fromhex( - "63000000000000000c00000007000000000000000c00000001000200" - ) - assert original.encode_bytes() == expected_encoding - assert OuterVarNested.decode_bytes(expected_encoding) == original - - -class TestSubclassInheritance: - """Pydantic merges parent and child fields in declaration order.""" - - def test_subclass_field_order_preserved(self) -> None: - """The subclass exposes parent fields first then its own fields.""" - assert list(SignedAttestation.model_fields.keys()) == ["slot", "data", "signature"] - - def test_subclass_roundtrip(self) -> None: - """A subclass that adds a fixed field after a variable field roundtrips correctly.""" - original = SignedAttestation( - slot=Uint64(5), - data=Uint16List4(data=[Uint16(1)]), - signature=Uint64(99), - ) - # Fixed part is slot (8) plus data offset (4) plus signature (8) for 20 bytes. - # Data offset value is therefore 20 and the payload is [1] as Uint16. - expected_encoding = bytes.fromhex("05000000000000001400000063000000000000000100") - assert original.encode_bytes() == expected_encoding - assert SignedAttestation.decode_bytes(expected_encoding) == original - - -class TestSerialize: - """Stream-level behavior of the serialize method.""" - - def test_serialize_returns_total_bytes_written(self) -> None: - """Serialize returns the total byte count including the variable tail.""" - original = OneVar(a=Uint16List4(data=[Uint16(1), Uint16(2), Uint16(3)])) - stream = io.BytesIO() - # Fixed part is 4 bytes for the single offset. - # The payload is 6 bytes for three Uint16 elements. - assert original.serialize(stream) == 10 - assert stream.getvalue() == bytes.fromhex("04000000010002000300") - - -class TestDeserialize: - """Stream-level behavior of the deserialize method.""" - - def test_deserialize_with_scope_reads_full_value(self) -> None: - """Reading from a stream with a matching scope reconstructs the value.""" - original = Mixed( - a=Uint64(1), - b=Uint16List4(data=[Uint16(7)]), - c=Uint32(2), - d=Uint16List4(data=[Uint16(8), Uint16(9)]), - ) - encoded = original.encode_bytes() - stream = io.BytesIO(encoded) - assert Mixed.deserialize(stream, len(encoded)) == original - - -class TestErrors: - """Spec-compliance error paths for malformed inputs.""" - - @pytest.mark.parametrize( - ("bad_offset", "expected_message"), - [ - pytest.param(11, "Mixed: first offset 11 != fixed-part end 20", id="below_fixed_end"), - pytest.param(21, "Mixed: first offset 21 != fixed-part end 20", id="above_fixed_end"), - ], - ) - def test_first_offset_must_match_fixed_part_end( - self, bad_offset: int, expected_message: str - ) -> None: - """The first variable offset must equal the end of the fixed part.""" - # Fixed part of Mixed is 8 + 4 + 4 + 4 = 20 bytes. - # The payload deviates by one byte in either direction from the canonical offset. - encoded_bytes = ( - (1).to_bytes(8, "little") - + bad_offset.to_bytes(4, "little") - + (2).to_bytes(4, "little") - + (24).to_bytes(4, "little") - + bytes.fromhex("01000200") - + bytes.fromhex("0300") - ) - with pytest.raises(SSZSerializationError) as exc_info: - Mixed.decode_bytes(encoded_bytes) - assert exc_info.value.args[0] == expected_message - - def test_non_monotonic_offsets_raise(self) -> None: - """A second offset below the first triggers a non-monotonic offsets error.""" - # Fixed part is 8 bytes for two Uint32 offsets. - # First offset is 8 (valid), second offset is 5 (decreasing). - encoded_bytes = (8).to_bytes(4, "little") + (5).to_bytes(4, "little") + b"\x34\x12" - with pytest.raises(SSZSerializationError) as exc_info: - TwoVar.decode_bytes(encoded_bytes) - assert exc_info.value.args[0] == "TwoVar.a: non-monotonic offsets (8 > 5)" - - def test_short_input_on_fixed_field_raises(self) -> None: - """A truncated stream on a fixed field surfaces the field type's own error.""" - # 15 bytes is one short of the 16-byte fixed width. - with pytest.raises(SSZSerializationError) as exc_info: - TwoUint64.decode_bytes(b"\x00" * 15) - assert exc_info.value.args[0] == "Uint64: expected 8 bytes, got 7" - - def test_trailing_bytes_raises(self) -> None: - """An input one byte longer than the canonical encoding is rejected.""" - with pytest.raises(SSZSerializationError) as exc_info: - TwoUint64.decode_bytes(b"\x00" * 17) - assert exc_info.value.args[0] == "TwoUint64: 1 trailing byte(s) after decode" - - -class TestFromHex: - """Hex-string entry point for container decoding.""" - - @pytest.mark.parametrize( - "hex_input", - [ - pytest.param("0xab", id="with_prefix"), - pytest.param("ab", id="without_prefix"), - pytest.param("0xAB", id="uppercase_with_prefix"), - ], - ) - def test_from_hex_accepts_prefix_and_case(self, hex_input: str) -> None: - """Hex parsing tolerates the 0x prefix and mixed case alike.""" - assert OneByte.from_hex(hex_input) == OneByte(a=Uint8(0xAB)) - - @pytest.mark.parametrize( - "hex_input", - [ - pytest.param("", id="empty"), - pytest.param("0x", id="prefix_only"), - ], - ) - def test_from_hex_empty_string_decodes_empty_container(self, hex_input: str) -> None: - """An empty hex string decodes to a zero-field container.""" - assert EmptyContainer.from_hex(hex_input) == EmptyContainer() - - def test_from_hex_bad_hex_raises_value_error(self) -> None: - """Non-hex characters surface a ValueError from the underlying parser.""" - with pytest.raises(ValueError) as exception_info: - OneByte.from_hex("zz") - assert ( - str(exception_info.value) - == "non-hexadecimal number found in fromhex() arg at position 0" - ) - - -class TestHexStringValidator: - """Pydantic validation accepts hex strings via the wrap validator.""" - - @pytest.mark.parametrize( - "hex_input", - [ - pytest.param("0xab", id="with_prefix"), - pytest.param("ab", id="without_prefix"), - pytest.param("0xAB", id="uppercase_with_prefix"), - ], - ) - def test_validates_hex_string(self, hex_input: str) -> None: - """Pydantic validation tolerates the 0x prefix and mixed case alike.""" - assert OneByte.model_validate(hex_input) == OneByte(a=Uint8(0xAB)) - - def test_validates_empty_string_as_empty_container(self) -> None: - """An empty hex string validates to a zero-field container.""" - assert EmptyContainer.model_validate("") == EmptyContainer() - - def test_dict_input_routes_to_field_validation(self) -> None: - """A dict input goes through field-by-field validation, not hex decoding.""" - assert OneByte.model_validate({"a": Uint8(0xAB)}) == OneByte(a=Uint8(0xAB)) - - def test_instance_input_passes_through(self) -> None: - """An existing instance input is returned unchanged.""" - instance = OneByte(a=Uint8(0xAB)) - assert OneByte.model_validate(instance) == instance - - def test_wrong_length_hex_raises_with_class_name(self) -> None: - """Hex with too many bytes raises a validation error tagged by the class name.""" - # 2 hex bytes ("abcd") cannot fit a 1-byte container; trailing bytes trigger the error. - # - # The trailing docs URL embeds the installed pydantic version, so it is anchored - # with a regex that pins every stable character and generalizes only the version. - with pytest.raises( - ValidationError, - match=( - r"(?s)^1 validation error for OneByte\n" - r" Value error, invalid OneByte hex: " - r"OneByte: 1 trailing byte\(s\) after decode " - r"\[type=value_error, input_value='abcd', input_type=str\]\n" - r" For further information visit " - r"https://errors\.pydantic\.dev/[^/]+/v/value_error\Z" - ), - ): - OneByte.model_validate("abcd") - - def test_nested_container_field_accepts_hex_string(self) -> None: - """A nested container field accepts a hex string for its own SSZ encoding.""" - # Fixture state: - # inner.x (Uint64) = 1, inner.y (Uint64) = 2 -> 16 little-endian bytes - outer = OuterFixedNested.model_validate( - { - "z": Uint64(7), - "inner": "01000000000000000200000000000000", - } - ) - assert outer == OuterFixedNested(z=Uint64(7), inner=InnerFixed(x=Uint64(1), y=Uint64(2))) - - -@given( - a=st.integers(min_value=0, max_value=2**64 - 1), - b=st.lists(st.integers(min_value=0, max_value=2**16 - 1), max_size=4), - c=st.integers(min_value=0, max_value=2**32 - 1), - d=st.lists(st.integers(min_value=0, max_value=2**16 - 1), max_size=4), -) -def test_mixed_container_round_trip_random_values( - a: int, b: list[int], c: int, d: list[int] -) -> None: - """Any mix of fixed and variable field values round-trips unchanged.""" - instance = Mixed( - a=Uint64(a), - b=Uint16List4(data=[Uint16(value) for value in b]), - c=Uint32(c), - d=Uint16List4(data=[Uint16(value) for value in d]), - ) - assert Mixed.decode_bytes(instance.encode_bytes()) == instance diff --git a/tests/spec/ssz/test_ssz_base.py b/tests/spec/ssz/test_ssz_base.py deleted file mode 100644 index dd3536816..000000000 --- a/tests/spec/ssz/test_ssz_base.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Tests for SSZModel and SSZType base class behavior.""" - -from lean_spec.spec.ssz import Uint8, Uint16, Uint64 -from lean_spec.spec.ssz.bitfields import BaseBitlist -from lean_spec.spec.ssz.boolean import Boolean -from lean_spec.spec.ssz.collections import SSZList -from lean_spec.spec.ssz.container import Container - - -class Uint16List4(SSZList[Uint16]): - """A list with up to 4 Uint16 values.""" - - LIMIT = 4 - - -class TwoFieldContainer(Container): - """A container with two fixed-size fields.""" - - x: Uint8 - y: Uint16 - - -class ThreeFieldContainer(Container): - """A container with three fields, one variable-size.""" - - a: Uint8 - b: Uint64 - c: Uint16List4 - - -class SmallBitlist(BaseBitlist): - """A bitlist with a small limit, used to test SSZModel.__len__ data path.""" - - LIMIT = 8 - - -class TestSSZModelLength: - """ - Tests for SSZModel.__len__() on both collection and container models. - - Uses BaseBitlist (not SSZList) for the data-path because SSZList overrides - __len__ with its own implementation. BaseBitlist inherits SSZModel's version. - """ - - def test_length_data_path_via_bitlist(self) -> None: - """BaseBitlist delegates to SSZModel.__len__ which returns len(data).""" - bl = SmallBitlist(data=(Boolean(True), Boolean(False), Boolean(True))) - assert len(bl) == 3 - - def test_length_empty_data_path_via_bitlist(self) -> None: - bl = SmallBitlist(data=()) - assert len(bl) == 0 - - def test_length_container_returns_field_count(self) -> None: - container = TwoFieldContainer(x=Uint8(1), y=Uint16(2)) - assert len(container) == 2 - - def test_length_three_field_container(self) -> None: - container = ThreeFieldContainer(a=Uint8(5), b=Uint64(42), c=Uint16List4(data=[Uint16(1)])) - assert len(container) == 3 - - -class TestSSZModelRepr: - """Tests for SSZModel.__repr__() on both collection and container models.""" - - def test_repr_collection_shows_data(self) -> None: - assert repr(Uint16List4(data=[Uint16(10), Uint16(20)])) == ( - "Uint16List4(data=[Uint16(10), Uint16(20)])" - ) - - def test_repr_empty_collection(self) -> None: - assert repr(Uint16List4(data=[])) == "Uint16List4(data=[])" - - def test_repr_container_shows_fields(self) -> None: - assert repr(TwoFieldContainer(x=Uint8(1), y=Uint16(2))) == ( - "TwoFieldContainer(x=Uint8(1) y=Uint16(2))" - ) - - def test_repr_three_field_container(self) -> None: - container = ThreeFieldContainer(a=Uint8(5), b=Uint64(42), c=Uint16List4(data=[Uint16(1)])) - assert repr(container) == ( - "ThreeFieldContainer(a=Uint8(5) b=Uint64(42) c=Uint16List4(data=[Uint16(1)]))" - ) - - -class TestSSZTypeEncodeDecode: - """ - Tests for encode_bytes/decode_bytes on SSZType. - - These methods wrap the stream-based serialize/deserialize interface - so callers can work with plain byte strings instead. - """ - - def test_encode_bytes_fixed_container(self) -> None: - container = TwoFieldContainer(x=Uint8(1), y=Uint16(2)) - encoded = container.encode_bytes() - assert encoded == b"\x01\x02\x00" - - def test_decode_bytes_fixed_container(self) -> None: - assert TwoFieldContainer.decode_bytes(b"\x01\x02\x00") == TwoFieldContainer( - x=Uint8(1), y=Uint16(2) - ) - - def test_encode_decode_roundtrip(self) -> None: - """Encoding then decoding must recover the original object.""" - original = TwoFieldContainer(x=Uint8(255), y=Uint16(1000)) - assert TwoFieldContainer.decode_bytes(original.encode_bytes()) == original diff --git a/tests/spec/ssz/test_uint.py b/tests/spec/ssz/test_uint.py deleted file mode 100644 index dcf8580ab..000000000 --- a/tests/spec/ssz/test_uint.py +++ /dev/null @@ -1,844 +0,0 @@ -"""Unsigned Integer Type Tests.""" - -import io -import operator -from itertools import permutations -from typing import Any, Type - -import pytest -from hypothesis import given, strategies as st -from pydantic import BaseModel, ValidationError - -from lean_spec.spec.ssz import Uint8, Uint16, Uint32, Uint64 -from lean_spec.spec.ssz.exceptions import SSZSerializationError, SSZTypeError, SSZValueError -from lean_spec.spec.ssz.uint import BaseUint - -ALL_UINT_TYPES = (Uint8, Uint16, Uint32, Uint64) -"""A collection of all Uint types to test against.""" - -CROSS_UINT_TYPE_PAIRS = list(permutations(ALL_UINT_TYPES, 2)) -"""Every ordered pair of distinct unsigned integer widths.""" - - -# Model classes for Pydantic validation tests -class Uint8Model(BaseModel): - value: Uint8 - - -class Uint16Model(BaseModel): - value: Uint16 - - -class Uint32Model(BaseModel): - value: Uint32 - - -class Uint64Model(BaseModel): - value: Uint64 - - -UINT_MODELS: dict[Type[BaseUint], Type[BaseModel]] = { - Uint8: Uint8Model, - Uint16: Uint16Model, - Uint32: Uint32Model, - Uint64: Uint64Model, -} -"""Mapping from Uint types to their corresponding Pydantic model classes.""" - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_pydantic_validation_accepts_valid_int(uint_class: Type[BaseUint]) -> None: - """Tests that Pydantic validation correctly accepts a valid integer.""" - model = UINT_MODELS[uint_class] - instance = model(value=10) - validated_value = instance.value # type: ignore[attribute-defined] - assert isinstance(validated_value, uint_class) - assert validated_value == uint_class(10) - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -@pytest.mark.parametrize("invalid_value", [1.0, "1", True, False]) -def test_pydantic_strict_mode_rejects_invalid_types( - uint_class: Type[BaseUint], invalid_value: Any -) -> None: - """Tests that Pydantic's strict mode rejects types that could be coerced to an int.""" - model = UINT_MODELS[uint_class] - with pytest.raises(ValidationError): - model(value=invalid_value) - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -@pytest.mark.parametrize( - "invalid_value, expected_type_name", - [ - (1.0, "float"), - ("1", "str"), - (True, "bool"), - (False, "bool"), - (b"1", "bytes"), - (None, "NoneType"), - ], -) -def test_instantiation_from_invalid_types_raises_error( - uint_class: Type[BaseUint], invalid_value: Any, expected_type_name: str -) -> None: - """Tests that instantiating with non-integer types raises SSZTypeError.""" - expected_message = f"Expected int, got {expected_type_name}" - with pytest.raises(SSZTypeError) as exception_info: - uint_class(invalid_value) - assert str(exception_info.value) == expected_message - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_instantiation_and_type(uint_class: Type[BaseUint]) -> None: - """Tests that Uint types are instances of `int` and their own class.""" - uint_instance = uint_class(5) - assert isinstance(uint_instance, int) - assert isinstance(uint_instance, BaseUint) - assert isinstance(uint_instance, uint_class) - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_instantiation_negative(uint_class: Type[BaseUint]) -> None: - """Tests that instantiating with a negative number raises SSZValueError.""" - expected_message = f"-5 out of range for {uint_class.__name__} [0, {2**uint_class.BITS - 1}]" - with pytest.raises(SSZValueError) as exception_info: - uint_class(-5) - assert str(exception_info.value) == expected_message - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_instantiation_too_large(uint_class: Type[BaseUint]) -> None: - """Tests that instantiating with a value >= MAX raises SSZValueError.""" - max_value = 2**uint_class.BITS - expected_message = f"{max_value} out of range for {uint_class.__name__} [0, {max_value - 1}]" - with pytest.raises(SSZValueError) as exception_info: - uint_class(max_value) - assert str(exception_info.value) == expected_message - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_max_method_returns_correct_value(uint_class: Type[BaseUint]) -> None: - """Tests that the max_value() class method returns the correct value.""" - expected_max_int = (2**uint_class.BITS) - 1 - assert uint_class.max_value() == uint_class(expected_max_int) - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_arithmetic_operators(uint_class: Type[BaseUint]) -> None: - """Tests all standard arithmetic operators.""" - # Use smaller values for high-bit integers to avoid massive numbers - a_value, b_value = (100, 3) if uint_class.BITS > 8 else (20, 3) - left = uint_class(a_value) - right = uint_class(b_value) - max_int = (2**uint_class.BITS) - 1 - max_value = uint_class(max_int) - name = uint_class.__name__ - - # Addition - assert left + right == uint_class(a_value + b_value) - expected_message = f"{max_int + b_value} out of range for {name} [0, {max_int}]" - with pytest.raises(SSZValueError) as exception_info: - _ = max_value + right - assert str(exception_info.value) == expected_message - - # Subtraction - assert left - right == uint_class(a_value - b_value) - expected_message = f"{b_value - a_value} out of range for {name} [0, {max_int}]" - with pytest.raises(SSZValueError) as exception_info: - _ = right - left - assert str(exception_info.value) == expected_message - - # Multiplication - assert left * right == uint_class(a_value * b_value) - expected_message = f"{max_int * b_value} out of range for {name} [0, {max_int}]" - with pytest.raises(SSZValueError) as exception_info: - _ = max_value * right - assert str(exception_info.value) == expected_message - - # Floor Division - assert left // right == uint_class(a_value // b_value) - - # Modulo - assert left % right == uint_class(a_value % b_value) - - # Exponentiation - assert uint_class(b_value) ** uint_class(4) == uint_class(b_value**4) - if uint_class.BITS <= 16: # Pow gets too big quickly - expected_message = f"{a_value**b_value} out of range for {name} [0, {max_int}]" - with pytest.raises(SSZValueError) as exception_info: - _ = left**right - assert str(exception_info.value) == expected_message - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_reverse_arithmetic_operators_raise_error(uint_class: Type[BaseUint]) -> None: - """Tests that reverse arithmetic operators raise a TypeError.""" - name = uint_class.__name__ - - expected_message = f"Unsupported operand type(s) for +: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = 100 + uint_class(3) - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for -: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = 100 - uint_class(3) - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for *: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = 100 * uint_class(3) - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for //: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = 100 // uint_class(3) - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for %: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = 100 % uint_class(3) - assert str(exception_info.value) == expected_message - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_divmod(uint_class: Type[BaseUint]) -> None: - """Tests the divmod function.""" - quotient, remainder = divmod(uint_class(100), uint_class(3)) - assert quotient == uint_class(33) - assert remainder == uint_class(1) - assert isinstance(quotient, uint_class) - assert isinstance(remainder, uint_class) - - expected_message = f"Unsupported operand type(s) for divmod: '{uint_class.__name__}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = divmod(100, uint_class(3)) - assert str(exception_info.value) == expected_message - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_inplace_immutability(uint_class: Type[BaseUint]) -> None: - """Tests that in-place operators return a new instance.""" - value1 = uint_class(10) - value2 = value1 - value1 += uint_class(5) - - assert isinstance(value1, uint_class) - assert value1 == uint_class(15) - # The original variable reference is unchanged - assert value2 == uint_class(10) - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_bitwise_operators(uint_class: Type[BaseUint]) -> None: - """Tests all standard bitwise operators.""" - left = uint_class(0b1100) # 12 - right = uint_class(0b1010) # 10 - name = uint_class.__name__ - - assert left & right == uint_class(0b1000) - assert left | right == uint_class(0b1110) - assert left ^ right == uint_class(0b0110) - assert left << uint_class(2) == uint_class(0b110000) - assert left >> uint_class(2) == uint_class(0b11) - - expected_message = f"Unsupported operand type(s) for &: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = left & 1 - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for |: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = left | 1 - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for ^: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = left ^ 1 - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for <<: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = left << 1 - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for >>: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = left >> 1 - assert str(exception_info.value) == expected_message - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_comparison_with_same_type(uint_class: Type[BaseUint]) -> None: - """Tests all comparison operators between two Uint instances.""" - assert uint_class(5) < uint_class(10) - assert uint_class(5) <= uint_class(10) - assert uint_class(10) == uint_class(10) - assert uint_class(10) != uint_class(5) - assert uint_class(10) > uint_class(5) - assert uint_class(10) >= uint_class(5) - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_all_comparisons_with_other_types_raise_error( - uint_class: Type[BaseUint], -) -> None: - """Tests that all comparisons with incompatible types raise TypeError.""" - name = uint_class.__name__ - - expected_message = f"Unsupported operand type(s) for ==: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = uint_class(10) == 10 - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for !=: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = 10 != uint_class(10) - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for >: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = uint_class(10) > 5 - assert str(exception_info.value) == expected_message - - # 5 < uint(10) routes to uint(10).__gt__(5) because uint is a strict int subclass. - expected_message = f"Unsupported operand type(s) for >: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = 5 < uint_class(10) - assert str(exception_info.value) == expected_message - - expected_message = f"Unsupported operand type(s) for >=: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = uint_class(10) >= 10 - assert str(exception_info.value) == expected_message - - # 10 <= uint(10) routes to uint(10).__ge__(10) by subclass priority. - expected_message = f"Unsupported operand type(s) for >=: '{name}' and 'int'" - with pytest.raises(TypeError) as exception_info: - _ = 10 <= uint_class(10) - assert str(exception_info.value) == expected_message - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_repr_and_str(uint_class: Type[BaseUint]) -> None: - """Tests the string and official representations.""" - uint_instance = uint_class(42) - assert str(uint_instance) == "42" - assert repr(uint_instance) == f"{uint_class.__name__}(42)" - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_hash(uint_class: Type[BaseUint]) -> None: - """Tests that the hash is distinct from a raw int.""" - assert hash(uint_class(1)) != hash(1) - assert hash(uint_class(1)) == hash(uint_class(1)) - assert hash(uint_class(1)) != hash(uint_class(2)) - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_index_list_access(uint_class: Type[BaseUint]) -> None: - """Tests that Uint types can be used directly for list indexing.""" - letters = ["a", "b", "c", "d", "e"] - index = uint_class(2) - assert letters[index] == "c" - assert letters[uint_class(0)] == "a" - assert letters[uint_class(4)] == "e" - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_index_slicing(uint_class: Type[BaseUint]) -> None: - """Tests that Uint types can be used in slice operations.""" - numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] - start = uint_class(2) - stop = uint_class(7) - step = uint_class(2) - - assert numbers[start:stop] == [2, 3, 4, 5, 6] - assert numbers[:stop] == [0, 1, 2, 3, 4, 5, 6] - assert numbers[start:] == [2, 3, 4, 5, 6, 7, 8, 9] - assert numbers[start:stop:step] == [2, 4, 6] - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_index_range(uint_class: Type[BaseUint]) -> None: - """Tests that Uint types can be used in range().""" - stop = uint_class(5) - single_argument_range = list(range(stop)) - assert single_argument_range == [0, 1, 2, 3, 4] - - start = uint_class(2) - stop = uint_class(8) - step = uint_class(2) - strided_range = list(range(start, stop, step)) - assert strided_range == [2, 4, 6] - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_index_hex_bin_oct(uint_class: Type[BaseUint]) -> None: - """Tests that Uint types work with hex(), bin(), oct().""" - uint_instance = uint_class(42) - assert hex(uint_instance) == "0x2a" - assert bin(uint_instance) == "0b101010" - assert oct(uint_instance) == "0o52" - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -def test_index_operator_index(uint_class: Type[BaseUint]) -> None: - """Tests that operator.index() works with Uint types.""" - uint_instance = uint_class(42) - assert operator.index(uint_instance) == 42 - assert isinstance(operator.index(uint_instance), int) - - -class TestUintSSZ: - """A collection of tests for the SSZ interface of Uint types.""" - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_is_fixed_size(self, uint_class: Type[BaseUint]) -> None: - """Tests that all Uint types are correctly identified as fixed-size.""" - assert uint_class.is_fixed_size() is True - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_get_byte_length(self, uint_class: Type[BaseUint]) -> None: - """Tests that the byte length is correctly calculated from the bit width.""" - expected_length = uint_class.BITS // 8 - assert uint_class.get_byte_length() == expected_length - - @pytest.mark.parametrize( - "uint_class, value, expected_hex", - [ - (Uint8, 0x00, "00"), - (Uint8, 0x01, "01"), - (Uint8, 0xAB, "ab"), - (Uint16, 0x0000, "0000"), - (Uint16, 0xABCD, "cdab"), - (Uint32, 0x00000000, "00000000"), - (Uint32, 0x01234567, "67452301"), - (Uint64, 0x0000000000000000, "0000000000000000"), - (Uint64, 0x0123456789ABCDEF, "efcdab8967452301"), - ], - ) - def test_encode_decode_roundtrip( - self, uint_class: Type[BaseUint], value: int, expected_hex: str - ) -> None: - """Tests the roundtrip of encoding and decoding for specific values.""" - # Create an instance of the specific Uint type. - instance = uint_class(value) - - # 1. Test encoding (serialization) - encoded = instance.encode_bytes() - assert encoded.hex() == expected_hex - - # 2. Test decoding (deserialization) - decoded = uint_class.decode_bytes(encoded) - assert decoded == instance - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_decode_bytes_invalid_length(self, uint_class: Type[BaseUint]) -> None: - """Tests that `decode_bytes` raises SSZSerializationError for wrong length data.""" - # Create byte string that is one byte too short. - expected_length = uint_class.get_byte_length() - invalid_data = b"\x00" * (expected_length - 1) - expected_message = ( - f"{uint_class.__name__}: expected {expected_length} bytes, got {expected_length - 1}" - ) - with pytest.raises(SSZSerializationError) as exception_info: - uint_class.decode_bytes(invalid_data) - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_serialize_deserialize_stream_roundtrip(self, uint_class: Type[BaseUint]) -> None: - """Tests the round trip of serializing to and deserializing from a stream.""" - # Create a test instance with a non-zero value. - instance = uint_class(123) - byte_length = uint_class.get_byte_length() - - # 1. Test serialization to a stream - stream = io.BytesIO() - bytes_written = instance.serialize(stream) - assert bytes_written == byte_length - stream.seek(0) # Rewind stream to the beginning for reading. - assert stream.read() == instance.encode_bytes() - - # 2. Test deserialization from a stream - stream.seek(0) # Rewind again for the deserialization test. - decoded = uint_class.deserialize(stream, scope=byte_length) - assert decoded == instance - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_deserialize_invalid_scope(self, uint_class: Type[BaseUint]) -> None: - """Tests that `deserialize` raises an SSZSerializationError if the scope is incorrect.""" - byte_length = uint_class.get_byte_length() - stream = io.BytesIO(b"\x00" * byte_length) - invalid_scope = byte_length - 1 - expected_message = ( - f"{uint_class.__name__}: invalid scope, " - f"expected {byte_length} bytes, got {invalid_scope}" - ) - with pytest.raises(SSZSerializationError) as exception_info: - uint_class.deserialize(stream, scope=invalid_scope) - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_deserialize_stream_too_short(self, uint_class: Type[BaseUint]) -> None: - """Tests that `deserialize` raises SSZSerializationError if stream ends prematurely.""" - byte_length = uint_class.get_byte_length() - # Create a stream that is shorter than what the type requires. - stream = io.BytesIO(b"\x00" * (byte_length - 1)) - expected_message = ( - f"{uint_class.__name__}: expected {byte_length} bytes, got {byte_length - 1}" - ) - with pytest.raises(SSZSerializationError) as exception_info: - uint_class.deserialize(stream, scope=byte_length) - assert str(exception_info.value) == expected_message - - -class TestForwardArithmeticTypeErrors: - """Tests that forward arithmetic operators reject plain int operands.""" - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - @pytest.mark.parametrize( - "method, op_symbol", - [ - ("__add__", "+"), - ("__sub__", "-"), - ("__mul__", "*"), - ("__floordiv__", "//"), - ("__mod__", "%"), - ], - ) - def test_forward_operator_rejects_plain_int( - self, uint_class: Type[BaseUint], method: str, op_symbol: str - ) -> None: - """Forward arithmetic operator raises TypeError when given a plain int.""" - # Call the dunder method directly with a plain int operand. - expected_message = ( - f"Unsupported operand type(s) for {op_symbol}: '{uint_class.__name__}' and 'int'" - ) - with pytest.raises(TypeError) as exception_info: - getattr(uint_class(5), method)(3) - assert str(exception_info.value) == expected_message - - -class TestReverseArithmeticSuccessPaths: - """Tests that reverse arithmetic operators succeed when both operands are BaseUint.""" - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_radd_success(self, uint_class: Type[BaseUint]) -> None: - """Reverse add returns the correct sum when called directly.""" - # __radd__(other) computes other + self - reverse_sum = uint_class(3).__radd__(uint_class(5)) - assert reverse_sum == uint_class(8) - assert isinstance(reverse_sum, uint_class) - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rsub_success(self, uint_class: Type[BaseUint]) -> None: - """Reverse sub returns the correct difference when called directly.""" - # __rsub__(other) computes other - self - reverse_difference = uint_class(3).__rsub__(uint_class(10)) - assert reverse_difference == uint_class(7) - assert isinstance(reverse_difference, uint_class) - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rmul_success(self, uint_class: Type[BaseUint]) -> None: - """Reverse mul returns the correct product when called directly.""" - # __rmul__(other) computes other * self - reverse_product = uint_class(3).__rmul__(uint_class(5)) - assert reverse_product == uint_class(15) - assert isinstance(reverse_product, uint_class) - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rfloordiv_success(self, uint_class: Type[BaseUint]) -> None: - """Reverse floordiv returns the correct quotient when called directly.""" - # __rfloordiv__(other) computes other // self - reverse_quotient = uint_class(3).__rfloordiv__(uint_class(10)) - assert reverse_quotient == uint_class(3) - assert isinstance(reverse_quotient, uint_class) - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rmod_success(self, uint_class: Type[BaseUint]) -> None: - """Reverse mod returns the correct remainder when called directly.""" - # __rmod__(other) computes other % self - reverse_remainder = uint_class(3).__rmod__(uint_class(10)) - assert reverse_remainder == uint_class(1) - assert isinstance(reverse_remainder, uint_class) - - -class TestPowAndRpow: - """Tests for exponentiation operators including modulo and reverse paths.""" - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_pow_with_modulo(self, uint_class: Type[BaseUint]) -> None: - """Three-argument pow(base, exp, mod) validates the modulo and returns correct result.""" - # pow(2, 10, 100) == 1024 % 100 == 24 - modular_power = pow(uint_class(2), uint_class(10), uint_class(100)) - assert modular_power == uint_class(24) - assert isinstance(modular_power, uint_class) - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rpow_success(self, uint_class: Type[BaseUint]) -> None: - """Reverse pow computes base ** self when called directly.""" - # __rpow__(base) computes base ** self => 2 ** 3 == 8 - reverse_power = uint_class(3).__rpow__(uint_class(2)) - assert reverse_power == uint_class(8) - assert isinstance(reverse_power, uint_class) - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rpow_with_modulo(self, uint_class: Type[BaseUint]) -> None: - """Three-argument reverse pow validates the modulo and returns the correct result.""" - # __rpow__(base, mod) computes pow(base, self, mod) => pow(2, 10, 100) == 24 - reverse_modular_power = uint_class(10).__rpow__(uint_class(2), uint_class(100)) - assert reverse_modular_power == uint_class(24) - assert isinstance(reverse_modular_power, uint_class) - - -class TestPowShiftStrictOperands: - """Pow and shift operators require same-type operands like every other binary op.""" - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - @pytest.mark.parametrize("bad", [3, True, "3", 1.5]) - def test_pow_rejects_non_uint_exponent(self, uint_class: Type[BaseUint], bad: Any) -> None: - """Exponentiation rejects any exponent of a different type.""" - expected_message = ( - f"Unsupported operand type(s) for **: " - f"'{uint_class.__name__}' and '{type(bad).__name__}'" - ) - with pytest.raises(TypeError) as exception_info: - uint_class(2) ** bad - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - @pytest.mark.parametrize("bad", [100, True]) - def test_pow_rejects_non_uint_modulo(self, uint_class: Type[BaseUint], bad: Any) -> None: - """Three-argument pow rejects any modulo of a different type.""" - expected_message = ( - f"Unsupported operand type(s) for **: " - f"'{uint_class.__name__}' and '{type(bad).__name__}'" - ) - with pytest.raises(TypeError) as exception_info: - pow(uint_class(2), uint_class(10), bad) - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - @pytest.mark.parametrize("bad", [2, True]) - def test_rpow_rejects_non_uint_base(self, uint_class: Type[BaseUint], bad: Any) -> None: - """Reverse pow rejects any base of a different type.""" - expected_message = ( - f"Unsupported operand type(s) for **: " - f"'{uint_class.__name__}' and '{type(bad).__name__}'" - ) - with pytest.raises(TypeError) as exception_info: - uint_class(3).__rpow__(bad) - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - @pytest.mark.parametrize("bad", [100, True]) - def test_rpow_rejects_non_uint_modulo(self, uint_class: Type[BaseUint], bad: Any) -> None: - """Three-argument reverse pow rejects any modulo of a different type.""" - expected_message = ( - f"Unsupported operand type(s) for **: " - f"'{uint_class.__name__}' and '{type(bad).__name__}'" - ) - with pytest.raises(TypeError) as exception_info: - uint_class(10).__rpow__(uint_class(2), bad) - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - @pytest.mark.parametrize("bad", [3, True]) - def test_lshift_rejects_non_uint(self, uint_class: Type[BaseUint], bad: Any) -> None: - """Left shift rejects any shift amount of a different type.""" - expected_message = ( - f"Unsupported operand type(s) for <<: " - f"'{uint_class.__name__}' and '{type(bad).__name__}'" - ) - with pytest.raises(TypeError) as exception_info: - uint_class(1) << bad - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - @pytest.mark.parametrize("bad", [2, True]) - def test_rshift_rejects_non_uint(self, uint_class: Type[BaseUint], bad: Any) -> None: - """Right shift rejects any shift amount of a different type.""" - expected_message = ( - f"Unsupported operand type(s) for >>: " - f"'{uint_class.__name__}' and '{type(bad).__name__}'" - ) - with pytest.raises(TypeError) as exception_info: - uint_class(8) >> bad - assert str(exception_info.value) == expected_message - - -class TestDivmodEdgeCases: - """Tests for divmod type error and reverse divmod paths.""" - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_divmod_rejects_plain_int(self, uint_class: Type[BaseUint]) -> None: - """Forward divmod raises TypeError when the divisor is a plain int.""" - expected_message = ( - f"Unsupported operand type(s) for divmod: '{uint_class.__name__}' and 'int'" - ) - with pytest.raises(TypeError) as exception_info: - divmod(uint_class(10), 3) - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rdivmod_success(self, uint_class: Type[BaseUint]) -> None: - """Reverse divmod returns correct (quotient, remainder) when called directly.""" - # __rdivmod__(other) computes divmod(other, self) => divmod(10, 3) == (3, 1) - quotient, remainder = uint_class(3).__rdivmod__(uint_class(10)) - assert quotient == uint_class(3) - assert remainder == uint_class(1) - assert isinstance(quotient, uint_class) - assert isinstance(remainder, uint_class) - - -class TestReverseBitwiseOperators: - """Tests for reverse bitwise operator delegation paths.""" - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rand_delegates_to_and(self, uint_class: Type[BaseUint]) -> None: - """Reverse AND delegates to forward AND and returns the correct result.""" - # __rand__ delegates to __and__ - reverse_and_result = uint_class(0b1100).__rand__(uint_class(0b1010)) - assert reverse_and_result == uint_class(0b1000) - assert isinstance(reverse_and_result, uint_class) - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_ror_delegates_to_or(self, uint_class: Type[BaseUint]) -> None: - """Reverse OR delegates to forward OR and returns the correct result.""" - # __ror__ delegates to __or__ - reverse_or_result = uint_class(0b1100).__ror__(uint_class(0b1010)) - assert reverse_or_result == uint_class(0b1110) - assert isinstance(reverse_or_result, uint_class) - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rxor_delegates_to_xor(self, uint_class: Type[BaseUint]) -> None: - """Reverse XOR delegates to forward XOR and returns the correct result.""" - # __rxor__ delegates to __xor__ - reverse_xor_result = uint_class(0b1100).__rxor__(uint_class(0b1010)) - assert reverse_xor_result == uint_class(0b0110) - assert isinstance(reverse_xor_result, uint_class) - - -class TestReverseShiftOperators: - """Tests for reverse left-shift and right-shift operator paths.""" - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rlshift_success(self, uint_class: Type[BaseUint]) -> None: - """Reverse left shift computes other << self.""" - # __rlshift__(other) computes other << self => 1 << 2 == 4 - reverse_left_shift = uint_class(2).__rlshift__(uint_class(1)) - assert reverse_left_shift == uint_class(4) - assert isinstance(reverse_left_shift, uint_class) - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - @pytest.mark.parametrize("bad", [1, True]) - def test_rlshift_rejects_non_uint(self, uint_class: Type[BaseUint], bad: Any) -> None: - """Reverse left shift rejects any operand of a different type.""" - expected_message = ( - f"Unsupported operand type(s) for <<: " - f"'{uint_class.__name__}' and '{type(bad).__name__}'" - ) - with pytest.raises(TypeError) as exception_info: - uint_class(2).__rlshift__(bad) - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_rrshift_success(self, uint_class: Type[BaseUint]) -> None: - """Reverse right shift computes other >> self.""" - # __rrshift__(other) computes other >> self => 8 >> 2 == 2 - reverse_right_shift = uint_class(2).__rrshift__(uint_class(8)) - assert reverse_right_shift == uint_class(2) - assert isinstance(reverse_right_shift, uint_class) - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - @pytest.mark.parametrize("bad", [8, True]) - def test_rrshift_rejects_non_uint(self, uint_class: Type[BaseUint], bad: Any) -> None: - """Reverse right shift rejects any operand of a different type.""" - expected_message = ( - f"Unsupported operand type(s) for >>: " - f"'{uint_class.__name__}' and '{type(bad).__name__}'" - ) - with pytest.raises(TypeError) as exception_info: - uint_class(2).__rrshift__(bad) - assert str(exception_info.value) == expected_message - - -class TestComparisonTypeErrors: - """Tests that comparison operators raise TypeError when given plain int operands.""" - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_lt_rejects_plain_int(self, uint_class: Type[BaseUint]) -> None: - """Less-than raises TypeError when compared to a plain int directly.""" - expected_message = f"Unsupported operand type(s) for <: '{uint_class.__name__}' and 'int'" - with pytest.raises(TypeError) as exception_info: - uint_class(5).__lt__(10) - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_le_rejects_plain_int(self, uint_class: Type[BaseUint]) -> None: - """Less-than-or-equal raises TypeError when compared to a plain int directly.""" - expected_message = f"Unsupported operand type(s) for <=: '{uint_class.__name__}' and 'int'" - with pytest.raises(TypeError) as exception_info: - uint_class(5).__le__(10) - assert str(exception_info.value) == expected_message - - -class TestIndexReturnsPlainInt: - """Tests that __index__ returns a plain int, not a BaseUint subclass.""" - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_index_returns_plain_int(self, uint_class: Type[BaseUint]) -> None: - """__index__ returns a plain int so that built-in operations receive a raw integer.""" - index_value = uint_class(42).__index__() - # The value must be correct. - assert index_value == 42 - # The type must be plain int, not a BaseUint subclass. - assert type(index_value) is int - - -class TestCrossWidthEqualityIsStrict: - """Equality across different unsigned integer widths must raise.""" - - @pytest.mark.parametrize("type_a, type_b", CROSS_UINT_TYPE_PAIRS) - def test_eq_across_widths_raises(self, type_a: Type[BaseUint], type_b: Type[BaseUint]) -> None: - """Equality across two distinct widths raises.""" - expected_message = ( - f"Unsupported operand type(s) for ==: '{type_a.__name__}' and '{type_b.__name__}'" - ) - with pytest.raises(TypeError) as exception_info: - _ = type_a(5) == type_b(5) - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("type_a, type_b", CROSS_UINT_TYPE_PAIRS) - def test_ne_across_widths_raises(self, type_a: Type[BaseUint], type_b: Type[BaseUint]) -> None: - """Inequality across two distinct widths raises.""" - expected_message = ( - f"Unsupported operand type(s) for !=: '{type_a.__name__}' and '{type_b.__name__}'" - ) - with pytest.raises(TypeError) as exception_info: - _ = type_a(5) != type_b(5) - assert str(exception_info.value) == expected_message - - @pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) - def test_eq_same_width_same_value_still_equal(self, uint_class: Type[BaseUint]) -> None: - """Within a single width, equal values still compare equal.""" - assert uint_class(7) == uint_class(7) - assert not (uint_class(7) != uint_class(7)) - - @pytest.mark.parametrize("type_a, type_b", CROSS_UINT_TYPE_PAIRS) - def test_hash_differs_across_widths( - self, type_a: Type[BaseUint], type_b: Type[BaseUint] - ) -> None: - """Equal-by-value instances of different widths hash differently.""" - assert hash(type_a(5)) != hash(type_b(5)) - - -@pytest.mark.parametrize("uint_class", ALL_UINT_TYPES) -@given(data=st.data()) -def test_encode_decode_round_trip_random_values(uint_class: Type[BaseUint], data) -> None: - """Any in-range value survives an encode and decode round trip unchanged.""" - raw_value = data.draw(st.integers(min_value=0, max_value=2**uint_class.BITS - 1)) - instance = uint_class(raw_value) - assert uint_class.decode_bytes(instance.encode_bytes()) == instance diff --git a/uv.lock b/uv.lock index 40a40a1ba..d2274665d 100644 --- a/uv.lock +++ b/uv.lock @@ -519,6 +519,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "eth-ssz-specs" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/63/f548c058bdfacb79a7b587514fedc7c2baa0140a1168fa2849d477805d21/eth_ssz_specs-0.1.0.tar.gz", hash = "sha256:0aa753a48e3c4779cebbe5fe21735b7e54956058f5a18f3cb0368ada105554ef", size = 99924, upload-time = "2026-09-01T21:51:43.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/14/945d4e800c4d4f1158ef352bb291c17ef9c925b61fd8551c156784a76a0f/eth_ssz_specs-0.1.0-py3-none-any.whl", hash = "sha256:466c6cef854cca45022a7cdc3922dd636e30b1a1dd5385845819e3d45ddddf41", size = 75538, upload-time = "2026-09-01T21:51:42.232Z" }, +] + [[package]] name = "execnet" version = "2.1.2" @@ -877,6 +889,7 @@ dependencies = [ { name = "aiohttp" }, { name = "aioquic" }, { name = "cryptography" }, + { name = "eth-ssz-specs" }, { name = "httpx" }, { name = "lean-multisig-py" }, { name = "numba" }, @@ -941,6 +954,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.11.0,<4" }, { name = "aioquic", specifier = ">=1.2.0,<2" }, { name = "cryptography", specifier = ">=46.0.0" }, + { name = "eth-ssz-specs", specifier = ">=0.1.0,<0.2" }, { name = "httpx", specifier = ">=0.28.0,<1" }, { name = "lean-multisig-py", git = "https://github.com/anshalshukla/leanMultisig-py?tag=v0.0.9" }, { name = "numba", specifier = ">=0.61.0,<1" }, diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 319861376..1947e6a14 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -13,22 +13,12 @@ # unseen by vulture. Genuinely dead code must stay out of this file so vulture # keeps reporting it. Prefer fixing the dead code over silencing it here. -# Single-dispatch handlers for the hash_tree_root generic function. -# Dispatched by argument type, so they have no direct call site. -_hash_tree_root_packed_leaf -_hash_tree_root_bytes -_hash_tree_root_bytelist -_hash_tree_root_bitvector_base -_hash_tree_root_bitlist_base -_hash_tree_root_vector -_hash_tree_root_list -_hash_tree_root_container - # Magic methods invoked by the interpreter. # Flagged only because they are defined as overloaded functions or as an # attribute rather than a plain method, which the built-in dunder filter misses. __pow__ __repr__ +__le__ _.__len__ # pytest hooks, discovered by name from the plugin and conftest modules. @@ -54,6 +44,7 @@ _._accept_hex_string _._validate_byte_list_data _._validate_decomposition +_._check_index_matches_position _._require_index_matches_position _.validate_state_length _.validate_target @@ -62,6 +53,7 @@ _._yaml_int_to_hex _._check_list_lengths _._reject_oversized_validator_set +_._check_index_matches_position _._require_index_matches_position # Pydantic serializers, invoked by the model during serialization. @@ -158,6 +150,11 @@ combined_attesters reaggregated_proof +# SSZ type parameters and overrides read through the SSZ base rather than by name. +# The base decides mutability and byte width from these, so no call site names them. +MUTABLE +_.fixed_size + # SSZ container and model field names declared inside unit tests. # Serialized by the SSZ codec or set through pydantic, never read by attribute. A @@ -165,6 +162,7 @@ C y first_name +previous slot_number _.slot_number