Skip to content

Commit d71e562

Browse files
tcoratgerclaude
andauthored
refactor(ssz): move hex-string parsing into BaseBytes and Container schemas (leanEthereum#795)
Four field validators across the codebase each rebuilt the same "if str, decode hex" logic on top of a Pydantic field. Promote the parsing to the schema layer so it happens once per type. - BaseBytes.__get_pydantic_core_schema__: add a str branch that routes any string through the constructor. - Container: add a model_validator(mode="wrap") that decodes a hex string input via from_hex, with other shapes passing through field-by-field validation. Removes: - validator/registry.py parse_pubkey — covered by the Bytes52 schema. - xmss/containers.py _decode_public_key, _decode_secret_key — covered by the Container wrap validator. Simplifies: - genesis/config.py keeps only the YAML quirk where an unquoted 0x-prefixed value is parsed as an int. Adds TestHexStringValidator in test_container.py covering every branch of the new wrap validator: hex success paths with prefix and case variants, empty-hex edge case, dict and instance pass-through, the class-name-tagged error on wrong length, and the nested-container field case. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 87943be commit d71e562

7 files changed

Lines changed: 100 additions & 65 deletions

File tree

src/lean_spec/node/genesis/config.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,16 @@ class GenesisValidatorEntry(StrictBaseModel):
3636

3737
@field_validator("attestation_pubkey", "proposal_pubkey", mode="before")
3838
@classmethod
39-
def parse_hex_pubkey(cls, v: Any) -> Bytes52:
39+
def _yaml_int_to_hex(cls, v: Any) -> Any:
4040
"""
41-
Convert hex strings or integers to validated Bytes52 pubkeys.
41+
Re-encode integer inputs as hex strings before standard validation.
4242
43-
YAML parsers may interpret 0x-prefixed values as integers.
43+
A YAML parser may interpret an unquoted 0x-prefixed value as an int.
44+
Converting it back to a hex string lets the byte-array schema handle it.
4445
"""
4546
if isinstance(v, int):
46-
v = f"0x{v:0104x}"
47-
return Bytes52(v)
47+
return f"0x{v:0104x}"
48+
return v
4849

4950

5051
class GenesisConfig(StrictBaseModel):

src/lean_spec/node/validator/registry.py

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from pathlib import Path
3131

3232
import yaml
33-
from pydantic import BaseModel, field_validator
33+
from pydantic import BaseModel
3434

3535
from lean_spec.spec.crypto.xmss import SecretKey
3636
from lean_spec.spec.forks import ValidatorIndex, ValidatorIndices
@@ -60,21 +60,6 @@ class ValidatorManifestEntry(BaseModel):
6060
proposal_privkey_file: str
6161
"""Filename of the proposal private key file."""
6262

63-
@field_validator("attestation_pubkey_hex", "proposal_pubkey_hex", mode="before")
64-
@classmethod
65-
def parse_pubkey(cls, v: object) -> Bytes52:
66-
"""
67-
Convert hex strings to validated Bytes52 pubkeys.
68-
69-
Only accepts hex strings and existing Bytes52 instances.
70-
Integers and other types are rejected.
71-
"""
72-
if isinstance(v, Bytes52):
73-
return v
74-
if isinstance(v, str):
75-
return Bytes52(v)
76-
raise TypeError(f"Expected hex string or Bytes52, got {type(v).__name__}")
77-
7863

7964
class ValidatorManifest(BaseModel):
8065
"""

src/lean_spec/spec/crypto/xmss/containers.py

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22

33
from typing import override
44

5-
from pydantic import field_serializer, field_validator, model_serializer
5+
from pydantic import field_serializer, model_serializer
66

77
from lean_spec.base import StrictBaseModel
88
from lean_spec.spec.forks.lstar.containers import Slot
99
from lean_spec.spec.ssz import Uint64
1010
from lean_spec.spec.ssz.container import Container
11-
from lean_spec.spec.ssz.exceptions import SSZError
1211

1312
from .constants import TARGET_CONFIG
1413
from .merkle import HashSubTree
@@ -143,34 +142,6 @@ class KeyPair(StrictBaseModel):
143142
secret_key: SecretKey
144143
"""Secret key."""
145144

146-
@field_validator("public_key", mode="before")
147-
@classmethod
148-
def _decode_public_key(cls, value: object) -> object:
149-
"""Decode hex strings to a public key.
150-
151-
Other input shapes pass through unchanged.
152-
"""
153-
if not isinstance(value, str):
154-
return value
155-
try:
156-
return PublicKey.from_hex(value)
157-
except SSZError as err:
158-
raise ValueError(f"invalid public key hex: {err}") from err
159-
160-
@field_validator("secret_key", mode="before")
161-
@classmethod
162-
def _decode_secret_key(cls, value: object) -> object:
163-
"""Decode hex strings to a secret key.
164-
165-
Other input shapes pass through unchanged.
166-
"""
167-
if not isinstance(value, str):
168-
return value
169-
try:
170-
return SecretKey.from_hex(value)
171-
except SSZError as err:
172-
raise ValueError(f"invalid secret key hex: {err}") from err
173-
174145
@field_serializer("public_key", "secret_key", when_used="json")
175146
def _encode_hex(self, value: PublicKey | SecretKey) -> str:
176147
"""Emit each half as plain hex in JSON mode only."""

src/lean_spec/spec/ssz/byte_arrays.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -159,30 +159,41 @@ def __get_pydantic_core_schema__(
159159
160160
- Already-typed instances pass through.
161161
- Plain bytes inputs go through length-checked validation, then get wrapped.
162+
- Hex string inputs (with an optional 0x prefix) go through the constructor.
162163
- JSON serialization converts the bytes to a 0x-prefixed hex string.
163164
"""
164-
# Validator that wraps a verified bytes object into a typed instance.
165-
from_bytes_validator = core_schema.no_info_plain_validator_function(cls)
165+
# Shared validator that runs the constructor on a verified input.
166+
# The constructor handles bytes, bytearray, hex strings, or iterables of ints.
167+
# It also enforces the declared length.
168+
from_input_validator = core_schema.no_info_plain_validator_function(cls)
166169

167-
# Two-step input validation:
168-
#
169-
# - bytes_schema enforces the exact declared length.
170-
# - wrapping validator turns the validated bytes into a typed instance.
171-
python_schema = core_schema.chain_schema(
170+
# Bytes path enforces the exact declared length, then wraps into a typed instance.
171+
bytes_path = core_schema.chain_schema(
172172
[
173173
core_schema.bytes_schema(min_length=cls.LENGTH, max_length=cls.LENGTH),
174-
from_bytes_validator,
174+
from_input_validator,
175+
]
176+
)
177+
178+
# Hex string path routes any string through the constructor.
179+
# The constructor strips an optional 0x prefix, decodes hex, and length-checks.
180+
str_path = core_schema.chain_schema(
181+
[
182+
core_schema.str_schema(),
183+
from_input_validator,
175184
]
176185
)
177186

178-
# Final union accepts either branch and serializes back to a 0x-prefixed hex string:
187+
# Final union accepts any branch and serializes back to a 0x-prefixed hex string:
179188
#
180189
# - Branch 1: input is already a typed instance, pass through.
181190
# - Branch 2: input is bytes that need length-checking and wrapping.
191+
# - Branch 3: input is a hex string that goes through the constructor.
182192
return core_schema.union_schema(
183193
[
184194
core_schema.is_instance_schema(cls),
185-
python_schema,
195+
bytes_path,
196+
str_path,
186197
],
187198
serialization=core_schema.plain_serializer_function_ser_schema(
188199
lambda x: "0x" + x.hex()

src/lean_spec/spec/ssz/container.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,35 @@
22

33
import io
44
from itertools import pairwise
5-
from typing import IO, Self, override
5+
from typing import IO, Any, Self, override
66

7-
from .exceptions import SSZSerializationError, SSZTypeError
7+
from pydantic import model_validator
8+
from pydantic.functional_validators import ModelWrapValidatorHandler
9+
10+
from .exceptions import SSZError, SSZSerializationError, SSZTypeError
811
from .ssz_base import BYTES_PER_LENGTH_OFFSET, SSZModel, SSZType
912
from .uint import Uint32
1013

1114

1215
class Container(SSZModel):
1316
"""Ordered struct of named heterogeneous SSZ fields."""
1417

18+
@model_validator(mode="wrap")
19+
@classmethod
20+
def _accept_hex_string(cls, value: Any, handler: ModelWrapValidatorHandler[Self]) -> Self:
21+
"""
22+
Reconstruct the container from a hex-encoded SSZ payload.
23+
24+
- Other input shapes pass through to field-by-field validation.
25+
- Hex strings accept an optional 0x prefix.
26+
"""
27+
if isinstance(value, str):
28+
try:
29+
return cls.from_hex(value)
30+
except SSZError as err:
31+
raise ValueError(f"invalid {cls.__name__} hex: {err}") from err
32+
return handler(value)
33+
1534
@classmethod
1635
@override
1736
def is_fixed_size(cls) -> bool:

tests/lean_spec/spec/crypto/xmss/test_containers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ def test_keypair_decodes_public_and_secret_hex(keypair_a: KeyPair) -> None:
238238

239239
def test_keypair_rejects_invalid_public_key_hex(keypair_a: KeyPair) -> None:
240240
"""A malformed public-key hex string surfaces as a validation error."""
241-
with pytest.raises(ValidationError, match="invalid public key hex"):
241+
with pytest.raises(ValidationError, match="invalid PublicKey hex"):
242242
KeyPair.model_validate(
243243
{
244244
"public_key": "deadbeef",
@@ -249,7 +249,7 @@ def test_keypair_rejects_invalid_public_key_hex(keypair_a: KeyPair) -> None:
249249

250250
def test_keypair_rejects_invalid_secret_key_hex(keypair_a: KeyPair) -> None:
251251
"""A malformed secret-key hex string surfaces as a validation error."""
252-
with pytest.raises(ValidationError, match="invalid secret key hex"):
252+
with pytest.raises(ValidationError, match="invalid SecretKey hex"):
253253
KeyPair.model_validate(
254254
{
255255
"public_key": keypair_a.public_key.encode_bytes().hex(),

tests/lean_spec/spec/ssz/test_container.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import io
44

55
import pytest
6+
from pydantic import ValidationError
67

78
from lean_spec.spec.ssz.collections import SSZList
89
from lean_spec.spec.ssz.container import Container
@@ -380,3 +381,50 @@ def test_from_hex_bad_hex_raises_value_error(self) -> None:
380381
"""Non-hex characters surface a ValueError from the underlying parser."""
381382
with pytest.raises(ValueError, match="non-hexadecimal number"):
382383
OneByte.from_hex("zz")
384+
385+
386+
class TestHexStringValidator:
387+
"""Pydantic validation accepts hex strings via the wrap validator."""
388+
389+
@pytest.mark.parametrize(
390+
"hex_input",
391+
[
392+
pytest.param("0xab", id="with_prefix"),
393+
pytest.param("ab", id="without_prefix"),
394+
pytest.param("0xAB", id="uppercase_with_prefix"),
395+
],
396+
)
397+
def test_validates_hex_string(self, hex_input: str) -> None:
398+
"""Pydantic validation tolerates the 0x prefix and mixed case alike."""
399+
assert OneByte.model_validate(hex_input) == OneByte(a=Uint8(0xAB))
400+
401+
def test_validates_empty_string_as_empty_container(self) -> None:
402+
"""An empty hex string validates to a zero-field container."""
403+
assert EmptyContainer.model_validate("") == EmptyContainer()
404+
405+
def test_dict_input_routes_to_field_validation(self) -> None:
406+
"""A dict input goes through field-by-field validation, not hex decoding."""
407+
assert OneByte.model_validate({"a": Uint8(0xAB)}) == OneByte(a=Uint8(0xAB))
408+
409+
def test_instance_input_passes_through(self) -> None:
410+
"""An existing instance input is returned unchanged."""
411+
instance = OneByte(a=Uint8(0xAB))
412+
assert OneByte.model_validate(instance) == instance
413+
414+
def test_wrong_length_hex_raises_with_class_name(self) -> None:
415+
"""Hex with too many bytes raises a validation error tagged by the class name."""
416+
# 2 hex bytes ("abcd") cannot fit a 1-byte container; trailing bytes trigger the error.
417+
with pytest.raises(ValidationError, match="invalid OneByte hex"):
418+
OneByte.model_validate("abcd")
419+
420+
def test_nested_container_field_accepts_hex_string(self) -> None:
421+
"""A nested container field accepts a hex string for its own SSZ encoding."""
422+
# Fixture state:
423+
# inner.x (Uint64) = 1, inner.y (Uint64) = 2 -> 16 little-endian bytes
424+
outer = OuterFixedNested.model_validate(
425+
{
426+
"z": Uint64(7),
427+
"inner": "01000000000000000200000000000000",
428+
}
429+
)
430+
assert outer == OuterFixedNested(z=Uint64(7), inner=InnerFixed(x=Uint64(1), y=Uint64(2)))

0 commit comments

Comments
 (0)