Skip to content

Commit bedfc47

Browse files
authored
types: add List and Vector (leanEthereum#42)
1 parent d190302 commit bedfc47

8 files changed

Lines changed: 1163 additions & 3 deletions

File tree

src/lean_spec/types/collections.py

Lines changed: 399 additions & 0 deletions
Large diffs are not rendered by default.

src/lean_spec/types/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Constants used throughout the library."""
2+
3+
OFFSET_BYTE_LENGTH = 4
4+
"""The number of bytes used to represent the offset of a variable-sized element."""

src/lean_spec/types/container.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""Container Type Specification."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
from typing import IO, Any, Dict, List, Tuple, Type, cast
7+
8+
from pydantic import BaseModel, ConfigDict
9+
from typing_extensions import Self
10+
11+
from lean_spec.types.constants import OFFSET_BYTE_LENGTH
12+
13+
from .ssz_base import SSZType
14+
from .uint import Uint32
15+
16+
17+
class Container(BaseModel, SSZType):
18+
"""
19+
A strict SSZ Container type: an ordered, heterogeneous collection of fields.
20+
21+
Inherit from this class to define a new container structure. Field types
22+
must be valid SSZ types.
23+
24+
Example:
25+
class BeaconBlockHeader(Container):
26+
slot: Uint64
27+
proposer_index: Uint64
28+
parent_root: Bytes32
29+
"""
30+
31+
# Configure all container subclasses to be strict and immutable by default.
32+
model_config = ConfigDict(strict=True, frozen=True)
33+
34+
# --- SSZType Implementation ---
35+
36+
@classmethod
37+
def is_fixed_size(cls) -> bool:
38+
"""
39+
Determine if the container is a fixed-size type.
40+
41+
A container is fixed-size if and only if all of its fields are fixed-size.
42+
43+
Returns:
44+
bool: True if all fields are fixed-size, False otherwise.
45+
"""
46+
# Iterate through the types of all fields defined in the model.
47+
for field_type in cls.model_fields.values():
48+
# The `annotation` attribute holds the actual type hint (e.g., `Uint64`).
49+
# We assume all field types are valid SSZ types and have `is_fixed_size`.
50+
if not cast(Type[SSZType], field_type.annotation).is_fixed_size():
51+
# If any field is variable-size, the container is variable-size.
52+
return False
53+
# If the loop completes, all fields are fixed-size.
54+
return True
55+
56+
@classmethod
57+
def get_byte_length(cls) -> int:
58+
"""
59+
Get the byte length of the container if it is fixed-size.
60+
61+
Raises:
62+
TypeError: If the container is not fixed-size.
63+
64+
Returns:
65+
int: The total byte length of all fields.
66+
"""
67+
# A byte length can only be determined for fixed-size containers.
68+
if not cls.is_fixed_size():
69+
raise TypeError(f"{cls.__name__} is not a fixed-size type.")
70+
71+
# The total length is the sum of the byte lengths of all its fields.
72+
return sum(
73+
cast(Type[SSZType], field.annotation).get_byte_length()
74+
for field in cls.model_fields.values()
75+
)
76+
77+
def serialize(self, stream: IO[bytes]) -> int:
78+
"""
79+
Serialize the container to a binary stream according to SSZ rules.
80+
81+
This method correctly handles the mixed serialization of fixed-size fields
82+
and offsets for variable-size fields.
83+
84+
Args:
85+
stream (IO[bytes]): The stream to write the serialized data to.
86+
87+
Returns:
88+
int: The total number of bytes written.
89+
"""
90+
# Separate fields into fixed and variable parts for serialization.
91+
fixed_parts: List[bytes] = []
92+
variable_parts: List[bytes] = []
93+
94+
# Iterate through all defined fields to process them in order.
95+
for field_name, field_info in type(self).model_fields.items():
96+
# Get the actual value of the field from the instance.
97+
value = getattr(self, field_name)
98+
# The field's type is its annotation (e.g., `Uint64`).
99+
field_type = cast(Type[SSZType], field_info.annotation)
100+
101+
# Check if the field type is fixed or variable size.
102+
if field_type.is_fixed_size():
103+
# For fixed-size fields, serialize the value directly.
104+
fixed_parts.append(value.encode_bytes())
105+
else:
106+
# For variable-size fields, add a placeholder for the offset
107+
# in the fixed part and serialize the value's data into the variable part.
108+
fixed_parts.append(b"") # Placeholder, will be replaced with offset.
109+
variable_parts.append(value.encode_bytes())
110+
111+
# Calculate the starting offset for the variable data. It begins after all fixed parts.
112+
current_offset = sum(
113+
part_len if part_len > 0 else OFFSET_BYTE_LENGTH for part_len in map(len, fixed_parts)
114+
)
115+
116+
# Write the fixed parts to the stream, replacing placeholders with calculated offsets.
117+
variable_part_index = 0
118+
for part in fixed_parts:
119+
# If the part is not a placeholder, write it directly.
120+
if part:
121+
stream.write(part)
122+
# If it is a placeholder, write the calculated offset instead.
123+
else:
124+
Uint32(current_offset).serialize(stream)
125+
# Update the offset for the next variable part.
126+
current_offset += len(variable_parts[variable_part_index])
127+
variable_part_index += 1
128+
129+
# Write all the serialized variable data at the end of the stream.
130+
for part in variable_parts:
131+
stream.write(part)
132+
133+
# The final offset value is the total number of bytes written.
134+
return current_offset
135+
136+
@classmethod
137+
def deserialize(cls, stream: IO[bytes], scope: int) -> Self:
138+
"""
139+
Deserialize a container from a binary stream.
140+
141+
Args:
142+
stream (IO[bytes]): The stream to read from.
143+
scope (int): The number of bytes available to read for this object.
144+
145+
Returns:
146+
Self: A new instance of the container with the deserialized data.
147+
"""
148+
# --- Phase 1: Read fixed data and gather variable field offsets ---
149+
deserialized_fields: Dict[str, Any] = {}
150+
variable_field_info: List[Tuple[str, Type[SSZType], int]] = []
151+
152+
# Read the fixed-size portion of the data from the stream.
153+
fixed_data_end = 0
154+
for field_name, field_info in cls.model_fields.items():
155+
field_type = cast(Type[SSZType], field_info.annotation)
156+
157+
if field_type.is_fixed_size():
158+
# Directly deserialize fixed-size fields.
159+
field_length = field_type.get_byte_length()
160+
field_data = stream.read(field_length)
161+
if len(field_data) != field_length:
162+
raise IOError(f"Stream ended prematurely while decoding field '{field_name}'")
163+
deserialized_fields[field_name] = field_type.decode_bytes(field_data)
164+
fixed_data_end += field_length
165+
else:
166+
# For variable fields, read the offset and store it for later processing.
167+
offset_data = stream.read(OFFSET_BYTE_LENGTH)
168+
if len(offset_data) != OFFSET_BYTE_LENGTH:
169+
raise IOError(
170+
f"Stream ended prematurely while reading offset for '{field_name}'"
171+
)
172+
offset = int(Uint32.decode_bytes(offset_data))
173+
variable_field_info.append((field_name, field_type, offset))
174+
fixed_data_end += OFFSET_BYTE_LENGTH
175+
176+
# --- Phase 2: Read variable data using the collected offsets ---
177+
if variable_field_info:
178+
# Add the total scope as the final offset boundary.
179+
offsets = [info[2] for info in variable_field_info] + [scope]
180+
181+
# Read the entire variable data block into memory.
182+
variable_data_length = scope - fixed_data_end
183+
variable_data = stream.read(variable_data_length)
184+
if len(variable_data) != variable_data_length:
185+
raise IOError("Stream ended prematurely while reading variable data block.")
186+
187+
# Deserialize each variable field from its slice of the data block.
188+
for i in range(len(variable_field_info)):
189+
field_name, field_type, start_offset = variable_field_info[i]
190+
end_offset = offsets[i + 1]
191+
192+
# The actual data slice is relative to the start of the variable block.
193+
slice_start = start_offset - fixed_data_end
194+
slice_end = end_offset - fixed_data_end
195+
196+
if slice_start > slice_end or slice_start < 0:
197+
raise ValueError(
198+
f"Invalid offsets for field '{field_name}': start > end or start < 0"
199+
)
200+
201+
field_data_slice = variable_data[slice_start:slice_end]
202+
deserialized_fields[field_name] = field_type.decode_bytes(field_data_slice)
203+
204+
# Construct the final object instance from the deserialized fields.
205+
return cls(**deserialized_fields)
206+
207+
def encode_bytes(self) -> bytes:
208+
"""Serializes the Container to a byte string."""
209+
with io.BytesIO() as stream:
210+
self.serialize(stream)
211+
return stream.getvalue()
212+
213+
@classmethod
214+
def decode_bytes(cls, data: bytes) -> Self:
215+
"""Deserializes a byte string into a Container instance."""
216+
with io.BytesIO(data) as stream:
217+
return cls.deserialize(stream, len(data))

src/lean_spec/types/ssz_base.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Base classes and interfaces for all SSZ types."""
2+
3+
from __future__ import annotations
4+
5+
from typing import IO
6+
7+
from typing_extensions import Self
8+
9+
10+
class SSZType:
11+
"""An abstract base class for all SSZ types."""
12+
13+
@classmethod
14+
def is_fixed_size(cls) -> bool:
15+
"""
16+
Check if the type has a fixed size in bytes.
17+
18+
Returns:
19+
bool: True if the size is fixed, False otherwise.
20+
"""
21+
raise NotImplementedError
22+
23+
@classmethod
24+
def get_byte_length(cls) -> int:
25+
"""
26+
Get the byte length of the type if it is fixed-size.
27+
28+
Raises:
29+
TypeError: If the type is not fixed-size.
30+
31+
Returns:
32+
int: The number of bytes.
33+
"""
34+
raise NotImplementedError
35+
36+
def encode_bytes(self) -> bytes:
37+
"""
38+
Serializes the SSZ object to a byte string.
39+
40+
Returns:
41+
bytes: The serialized byte string.
42+
"""
43+
raise NotImplementedError
44+
45+
@classmethod
46+
def decode_bytes(cls, data: bytes) -> Self:
47+
"""
48+
Deserializes a byte string into an SSZ object.
49+
50+
Args:
51+
data (bytes): The byte string to deserialize.
52+
53+
Returns:
54+
Self: An instance of the class.
55+
"""
56+
raise NotImplementedError
57+
58+
def serialize(self, stream: IO[bytes]) -> int:
59+
"""
60+
Serializes the object and writes it to a binary stream.
61+
62+
Args:
63+
stream (IO[bytes]): The stream to write the serialized data to.
64+
65+
Returns:
66+
int: The number of bytes written.
67+
"""
68+
raise NotImplementedError
69+
70+
@classmethod
71+
def deserialize(cls, stream: IO[bytes], scope: int) -> Self:
72+
"""
73+
Deserializes an object from a binary stream within a given scope.
74+
75+
Args:
76+
stream (IO[bytes]): The stream to read from.
77+
scope (int): The number of bytes available to read for this object.
78+
79+
Returns:
80+
Self: An instance of the class.
81+
"""
82+
raise NotImplementedError

0 commit comments

Comments
 (0)