Skip to content

Commit ad24371

Browse files
authored
types: implement SSZType for boolean and bitfiels (leanEthereum#43)
1 parent bedfc47 commit ad24371

4 files changed

Lines changed: 292 additions & 45 deletions

File tree

src/lean_spec/types/bitfields.py

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from typing import (
6+
IO,
67
Any,
78
ClassVar,
89
Dict,
@@ -18,12 +19,13 @@
1819
from typing_extensions import List, Self
1920

2021
from .boolean import Boolean
22+
from .ssz_base import SSZType
2123

2224
_BITVECTOR_CACHE: Dict[Tuple[Type[Any], int], Type[Bitvector]] = {}
2325
"""A cache to store and reuse dynamically generated Bitvector types."""
2426

2527

26-
class Bitvector(tuple[Boolean, ...]):
28+
class Bitvector(tuple[Boolean, ...], SSZType):
2729
"""A strict Bitvector type: a fixed-length, immutable sequence of booleans."""
2830

2931
LENGTH: ClassVar[int]
@@ -142,6 +144,37 @@ def __get_pydantic_core_schema__(
142144
),
143145
)
144146

147+
@classmethod
148+
def is_fixed_size(cls) -> bool:
149+
"""Return whether the type is fixed-size."""
150+
return True
151+
152+
@classmethod
153+
def get_byte_length(cls) -> int:
154+
"""Return the byte length of the type."""
155+
if not hasattr(cls, "LENGTH"):
156+
raise TypeError("Cannot get length of raw Bitvector type.")
157+
return (cls.LENGTH + 7) // 8
158+
159+
def serialize(self, stream: IO[bytes]) -> int:
160+
"""Serialize the bitvector to a binary stream."""
161+
encoded_data = self.encode_bytes()
162+
stream.write(encoded_data)
163+
return len(encoded_data)
164+
165+
@classmethod
166+
def deserialize(cls, stream: IO[bytes], scope: int) -> Self:
167+
"""Deserialize a bitvector from a binary stream."""
168+
byte_length = cls.get_byte_length()
169+
if scope != byte_length:
170+
raise ValueError(
171+
f"Invalid scope for {cls.__name__}: expected {byte_length}, got {scope}"
172+
)
173+
data = stream.read(byte_length)
174+
if len(data) != byte_length:
175+
raise IOError(f"Stream ended prematurely while decoding {cls.__name__}")
176+
return cls.decode_bytes(data)
177+
145178
def encode_bytes(self) -> bytes:
146179
"""Serializes the Bitvector into a byte string according to SSZ spec."""
147180
# Calculate the number of bytes required to hold all bits.
@@ -192,7 +225,7 @@ def __repr__(self) -> str:
192225
"""A cache to store and reuse dynamically generated Bitlist types."""
193226

194227

195-
class Bitlist(list[Boolean]):
228+
class Bitlist(list[Boolean], SSZType):
196229
"""
197230
A strict Bitlist type: a variable-length, mutable sequence of booleans
198231
with a maximum capacity.
@@ -307,13 +340,38 @@ def __get_pydantic_core_schema__(
307340
),
308341
)
309342

343+
@classmethod
344+
def is_fixed_size(cls) -> bool:
345+
"""Return whether the type is fixed-size."""
346+
return False
347+
348+
@classmethod
349+
def get_byte_length(cls) -> int:
350+
"""Raise TypeError, as the type is variable-size."""
351+
raise TypeError(f"Type {cls.__name__} is not fixed-size")
352+
353+
def serialize(self, stream: IO[bytes]) -> int:
354+
"""Serialize the bitlist to a binary stream."""
355+
encoded_data = self.encode_bytes()
356+
stream.write(encoded_data)
357+
return len(encoded_data)
358+
359+
@classmethod
360+
def deserialize(cls, stream: IO[bytes], scope: int) -> Self:
361+
"""Deserialize a bitlist from a binary stream."""
362+
data = stream.read(scope)
363+
if len(data) != scope:
364+
raise IOError(f"Stream ended prematurely while decoding {cls.__name__}")
365+
return cls.decode_bytes(data)
366+
310367
def encode_bytes(self) -> bytes:
311368
"""Serializes the Bitlist into a byte string with a trailing delimiter bit."""
312369
# Get the number of bits in the list.
313370
num_bits = len(self)
314-
# The required byte length is the ceiling of (bits + 1) / 8.
315-
byte_len = (num_bits + 8) // 8
316-
# Create a mutable byte array.
371+
if num_bits == 0:
372+
return b"\x01"
373+
374+
byte_len = (num_bits + 7) // 8
317375
byte_array = bytearray(byte_len)
318376

319377
# Pack the bits into the byte array.
@@ -323,12 +381,16 @@ def encode_bytes(self) -> bytes:
323381
bit_index_in_byte = i % 8
324382
byte_array[byte_index] |= 1 << bit_index_in_byte
325383

326-
# Add the mandatory delimiter bit at the position right after the last data bit.
327-
delimiter_byte_index = num_bits // 8
328-
delimiter_bit_index = num_bits % 8
329-
byte_array[delimiter_byte_index] |= 1 << delimiter_bit_index
330-
331-
return bytes(byte_array)
384+
# Add the mandatory delimiter bit.
385+
if num_bits % 8 == 0:
386+
# If the data perfectly fills the last byte, append a new byte for the delimiter.
387+
return bytes(byte_array) + b"\x01"
388+
else:
389+
# Otherwise, set the bit after the last data bit in the existing last byte.
390+
delimiter_byte_index = num_bits // 8
391+
delimiter_bit_index = num_bits % 8
392+
byte_array[delimiter_byte_index] |= 1 << delimiter_bit_index
393+
return bytes(byte_array)
332394

333395
@classmethod
334396
def decode_bytes(cls, data: bytes) -> Self:
@@ -338,7 +400,7 @@ def decode_bytes(cls, data: bytes) -> Self:
338400
raise TypeError("Cannot decode to raw Bitlist; specify a limit, e.g., `Bitlist[4]`.")
339401
# The encoded data must not be empty (it must at least contain the delimiter).
340402
if not data:
341-
raise ValueError("Cannot decode empty bytes into a Bitlist.")
403+
raise ValueError("Invalid Bitlist encoding: data cannot be empty.")
342404

343405
# The length in bits is determined by finding the position of the delimiter.
344406
num_bits = (len(data) - 1) * 8

src/lean_spec/types/boolean.py

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

33
from __future__ import annotations
44

5-
from typing import Any
5+
from typing import IO, Any
66

77
from pydantic.annotated_handlers import GetCoreSchemaHandler
88
from pydantic_core import CoreSchema, core_schema
99
from typing_extensions import Self
1010

11+
from .ssz_base import SSZType
1112

12-
class Boolean(int):
13+
14+
class Boolean(int, SSZType):
1315
"""
1416
A strict SSZ Boolean type that inherits from `int` for `True`/`False` representation.
1517
@@ -70,6 +72,16 @@ def __get_pydantic_core_schema__(
7072
serialization=core_schema.plain_serializer_function_ser_schema(bool),
7173
)
7274

75+
@classmethod
76+
def is_fixed_size(cls) -> bool:
77+
"""Return whether the type is fixed-size."""
78+
return True
79+
80+
@classmethod
81+
def get_byte_length(cls) -> int:
82+
"""Return the byte length of the type."""
83+
return 1
84+
7385
def encode_bytes(self) -> bytes:
7486
r"""
7587
Serializes the boolean to its SSZ byte representation.
@@ -78,6 +90,31 @@ def encode_bytes(self) -> bytes:
7890
"""
7991
return b"\x01" if self else b"\x00"
8092

93+
@classmethod
94+
def decode_bytes(cls, data: bytes) -> Self:
95+
"""Deserialize a single byte into a Boolean instance."""
96+
if len(data) != 1:
97+
raise ValueError(f"Expected 1 byte for Boolean, got {len(data)}")
98+
if data[0] not in (0, 1):
99+
raise ValueError(f"Boolean byte must be 0x00 or 0x01, got {data[0]:#04x}")
100+
return cls(data[0])
101+
102+
def serialize(self, stream: IO[bytes]) -> int:
103+
"""Serialize the boolean to a binary stream."""
104+
encoded_data = self.encode_bytes()
105+
stream.write(encoded_data)
106+
return len(encoded_data)
107+
108+
@classmethod
109+
def deserialize(cls, stream: IO[bytes], scope: int) -> Self:
110+
"""Deserialize a boolean from a binary stream."""
111+
if scope != 1:
112+
raise ValueError(f"Invalid scope for Boolean: expected 1, got {scope}")
113+
data = stream.read(1)
114+
if len(data) != 1:
115+
raise IOError("Stream ended prematurely while decoding Boolean")
116+
return cls.decode_bytes(data)
117+
81118
def _raise_type_error(self, other: Any, op_symbol: str) -> None:
82119
"""Helper to raise a consistent TypeError for unsupported operations."""
83120
raise TypeError(

tests/lean_spec/types/test_bitfields.py

Lines changed: 102 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
"""Tests for the Bitvector and Bitlist types."""
1+
""" "Tests for the Bitvector and Bitlist types."""
22

3+
import io
34
from typing import Any
45

56
import pytest
@@ -201,28 +202,6 @@ class TestBitfieldSerialization:
201202
(4, (False, True, False, True), "0a"),
202203
(3, (False, True, False), "02"),
203204
(10, (1, False, True, False, False, False, True, True, False, True), "c502"),
204-
(
205-
16,
206-
(
207-
True,
208-
False,
209-
True,
210-
False,
211-
False,
212-
False,
213-
True,
214-
True,
215-
False,
216-
True,
217-
False,
218-
False,
219-
False,
220-
False,
221-
True,
222-
True,
223-
),
224-
"c5c2",
225-
),
226205
],
227206
)
228207
def test_bitvector_serialization_deserialization(
@@ -274,7 +253,106 @@ def test_bitvector_decode_invalid_length(self) -> None:
274253
def test_bitlist_decode_invalid_data(self) -> None:
275254
"""Tests that Bitlist.decode_bytes fails for invalid byte strings."""
276255
list_type = Bitlist[8] # type: ignore
277-
with pytest.raises(ValueError, match="Cannot decode empty bytes"):
256+
with pytest.raises(ValueError, match="data cannot be empty"):
278257
list_type.decode_bytes(b"")
279258
with pytest.raises(ValueError, match="last byte cannot be zero"):
280259
list_type.decode_bytes(b"\xff\x00")
260+
261+
262+
class TestBitfieldSSZ:
263+
"""Tests the SSZType interface methods for bitfields."""
264+
265+
def test_bitvector_ssz_properties(self) -> None:
266+
vec_type = Bitvector[10] # type: ignore
267+
assert vec_type.is_fixed_size() is True
268+
assert vec_type.get_byte_length() == 2 # (10+7)//8
269+
270+
def test_bitlist_ssz_properties(self) -> None:
271+
list_type = Bitlist[10] # type: ignore
272+
assert list_type.is_fixed_size() is False
273+
with pytest.raises(TypeError):
274+
list_type.get_byte_length()
275+
276+
def test_bitvector_deserialize_invalid_scope(self) -> None:
277+
vec_type = Bitvector[8] # type: ignore
278+
stream = io.BytesIO(b"\xff")
279+
with pytest.raises(ValueError, match="Invalid scope"):
280+
vec_type.deserialize(stream, scope=2)
281+
282+
def test_bitvector_deserialize_premature_end(self) -> None:
283+
vec_type = Bitvector[16] # type: ignore
284+
stream = io.BytesIO(b"\xff") # Only 1 byte, expects 2
285+
with pytest.raises(IOError, match="Stream ended prematurely"):
286+
vec_type.deserialize(stream, scope=2)
287+
288+
def test_bitlist_deserialize_premature_end(self) -> None:
289+
list_type = Bitlist[16] # type: ignore
290+
stream = io.BytesIO(b"\xff") # Only 1 byte
291+
with pytest.raises(IOError, match="Stream ended prematurely"):
292+
list_type.deserialize(stream, scope=2) # Scope says to read 2
293+
294+
@pytest.mark.parametrize(
295+
"length,value,expected_hex",
296+
[
297+
(8, (1, 1, 0, 1, 0, 1, 0, 0), "2b"),
298+
(4, (0, 1, 0, 1), "0a"),
299+
(3, (0, 1, 0), "02"),
300+
(10, (1, 0, 1, 0, 0, 0, 1, 1, 0, 1), "c502"),
301+
(16, (1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1), "c5c2"),
302+
(512, tuple([1] * 512), "ff" * 64),
303+
(513, tuple([1] * 513), ("ff" * 64) + "01"),
304+
],
305+
)
306+
def test_bitvector_encode_decode(
307+
self, length: int, value: Tuple[int, ...], expected_hex: str
308+
) -> None:
309+
vec_t = Bitvector[length] # type: ignore
310+
instance = vec_t(value)
311+
encoded = instance.encode_bytes()
312+
assert encoded.hex() == expected_hex
313+
314+
# round-trip via classmethod
315+
decoded = vec_t.decode_bytes(encoded)
316+
assert decoded == instance
317+
318+
# round-trip via stream serialize/deserialize
319+
stream = io.BytesIO()
320+
written = instance.serialize(stream)
321+
assert written == vec_t.get_byte_length()
322+
stream.seek(0)
323+
decoded2 = vec_t.deserialize(stream, scope=written)
324+
assert decoded2 == instance
325+
326+
@pytest.mark.parametrize(
327+
"limit,value,expected_hex",
328+
[
329+
(8, (), "01"),
330+
(8, (1, 1, 0, 1, 0, 1, 0, 0), "2b01"),
331+
(4, (0, 1, 0, 1), "1a"),
332+
(3, (0, 1, 0), "0a"),
333+
(16, (1, 0, 1, 0, 0, 0, 1, 1, 0, 1), "c506"),
334+
(512, (1,), "03"),
335+
(512, tuple([1] * 512), ("ff" * 64) + "01"),
336+
(513, tuple([1] * 513), ("ff" * 64) + "03"),
337+
],
338+
)
339+
def test_bitlist_encode_decode(
340+
self, limit: int, value: Tuple[int, ...], expected_hex: str
341+
) -> None:
342+
list_t = Bitlist[limit] # type: ignore
343+
instance = list_t(value)
344+
encoded = instance.encode_bytes()
345+
assert encoded.hex() == expected_hex
346+
347+
# round-trip via classmethod
348+
decoded = list_t.decode_bytes(encoded)
349+
assert decoded == instance
350+
351+
# round-trip via stream serialize/deserialize
352+
stream = io.BytesIO()
353+
written = instance.serialize(stream)
354+
# variable-size, so we assert the written size matches the encoding length
355+
assert written == len(encoded)
356+
stream.seek(0)
357+
decoded2 = list_t.deserialize(stream, scope=written)
358+
assert decoded2 == instance

0 commit comments

Comments
 (0)