Skip to content

Commit 7bdc968

Browse files
authored
fp: make koalabear fp a SSZType (leanEthereum#199)
1 parent 050fa4a commit 7bdc968

3 files changed

Lines changed: 198 additions & 25 deletions

File tree

src/lean_spec/subspecs/koalabear/field.py

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
"""Core definition of the KoalaBear prime field Fp."""
22

3-
from typing import Self
3+
from typing import IO, Self
44

5-
from pydantic import Field, field_validator
6-
7-
from lean_spec.types import StrictBaseModel
5+
from lean_spec.types import SSZType
86

97
# =================================================================
108
# Field Constants
@@ -72,16 +70,61 @@
7270
# =================================================================
7371

7472

75-
class Fp(StrictBaseModel):
76-
"""An element in the KoalaBear prime field F_p."""
73+
class Fp(SSZType):
74+
"""
75+
An element in the KoalaBear prime field F_p.
76+
77+
This is an SSZ-serializable type.
78+
79+
Each field element is represented as a 4-byte little-endian unsigned integer.
80+
"""
81+
82+
def __init__(self, value: int) -> None:
83+
"""
84+
Create a field element.
85+
86+
Args:
87+
value: The value to wrap. Must be in the range [0, P).
88+
89+
Negative values will be normalized to the range [0, P).
90+
91+
Raises:
92+
TypeError: If value is not an integer.
93+
"""
94+
if not isinstance(value, int):
95+
raise TypeError(f"Field value must be an integer, got {type(value).__name__}")
96+
97+
# Normalize to [0, P) - handles negative values correctly
98+
self.value: int = value % P
99+
100+
@classmethod
101+
def is_fixed_size(cls) -> bool:
102+
"""Fp elements are fixed-size (4 bytes)."""
103+
return True
104+
105+
@classmethod
106+
def get_byte_length(cls) -> int:
107+
"""Get the byte length of an Fp element."""
108+
return P_BYTES
77109

78-
value: int = Field(ge=0, lt=P, description="Field element value in the range [0, P)")
110+
def serialize(self, stream: IO[bytes]) -> int:
111+
"""Serialize the field element to a binary stream."""
112+
data = self.value.to_bytes(P_BYTES, byteorder="little")
113+
stream.write(data)
114+
return len(data)
79115

80-
@field_validator("value", mode="before")
81116
@classmethod
82-
def reduce_modulo_p(cls, v: int) -> int:
83-
"""Reduces an integer input modulo P before validation."""
84-
return v % P
117+
def deserialize(cls, stream: IO[bytes], scope: int) -> Self:
118+
"""Deserialize a field element from a binary stream."""
119+
if scope != P_BYTES:
120+
raise ValueError(f"Expected {P_BYTES} bytes for Fp, got {scope}")
121+
data = stream.read(P_BYTES)
122+
if len(data) != P_BYTES:
123+
raise ValueError(f"Expected {P_BYTES} bytes for Fp, got {len(data)}")
124+
value = int.from_bytes(data, byteorder="little")
125+
if value >= P:
126+
raise ValueError(f"Value {value} exceeds field modulus {P}")
127+
return cls(value=value)
85128

86129
def __add__(self, other: Self) -> Self:
87130
"""Field addition."""
@@ -135,6 +178,20 @@ def two_adic_generator(cls, bits: int) -> Self:
135178
raise ValueError(f"bits must be between 0 and {TWO_ADICITY}")
136179
return cls(value=TWO_ADIC_GENERATORS[bits])
137180

181+
def __eq__(self, other: object) -> bool:
182+
"""Check equality of two field elements."""
183+
if not isinstance(other, Fp):
184+
return False
185+
return self.value == other.value
186+
187+
def __hash__(self) -> int:
188+
"""Compute hash of the field element."""
189+
return hash(self.value)
190+
191+
def __repr__(self) -> str:
192+
"""String representation."""
193+
return f"Fp(value={self.value})"
194+
138195
def __bytes__(self) -> bytes:
139196
"""
140197
Serialize the field element using Python's bytes protocol.
@@ -150,7 +207,7 @@ def __bytes__(self) -> bytes:
150207
>>> len(data) == 4
151208
True
152209
"""
153-
return self.value.to_bytes(P_BYTES, byteorder="little")
210+
return self.encode_bytes()
154211

155212
@classmethod
156213
def from_bytes(cls, data: bytes) -> Self:
@@ -175,15 +232,7 @@ def from_bytes(cls, data: bytes) -> Self:
175232
>>> recovered == fp
176233
True
177234
"""
178-
if len(data) != P_BYTES:
179-
raise ValueError(f"Expected {P_BYTES} bytes, got {len(data)}")
180-
181-
value = int.from_bytes(data, byteorder="little")
182-
183-
if value >= P:
184-
raise ValueError(f"Value {value} (0x{value:08x}) exceeds field modulus {P} (0x{P:08x})")
185-
186-
return cls(value=value)
235+
return cls.decode_bytes(data)
187236

188237
@classmethod
189238
def serialize_list(cls, elements: list[Self]) -> bytes:

src/lean_spec/types/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .byte_arrays import ZERO_HASH, Bytes32, Bytes52, Bytes3116
77
from .collections import SSZList, SSZVector
88
from .container import Container
9+
from .ssz_base import SSZType
910
from .uint import Uint64
1011
from .validator import ValidatorIndex, is_proposer
1112

@@ -22,6 +23,7 @@
2223
"is_proposer",
2324
"SSZList",
2425
"SSZVector",
26+
"SSZType",
2527
"Boolean",
2628
"Container",
2729
]

tests/lean_spec/subspecs/koalabear/test_field.py

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ def test_base_field_arithmetic() -> None:
3838
# Test equality against the same and different types
3939
assert a == Fp(value=5)
4040
assert a != b
41-
assert a != 5 # type: ignore[comparison-overlap]
42-
assert a != "5" # type: ignore[comparison-overlap]
41+
assert a != 5
42+
assert a != "5"
4343

4444
# Test error on inverting the zero element
4545
with pytest.raises(ZeroDivisionError, match="Cannot invert the zero element."):
@@ -91,10 +91,10 @@ def test_bytes_protocol() -> None:
9191
assert Fp.from_bytes(bytes(fp)) == fp
9292

9393
# Test error handling for invalid data length
94-
with pytest.raises(ValueError, match="Expected 4 bytes, got 3"):
94+
with pytest.raises(ValueError, match="Expected 4 bytes for Fp, got 3"):
9595
Fp.from_bytes(b"\x01\x02\x03")
9696

97-
with pytest.raises(ValueError, match="Expected 4 bytes, got 5"):
97+
with pytest.raises(ValueError, match="Expected 4 bytes for Fp, got 5"):
9898
Fp.from_bytes(b"\x01\x02\x03\x04\x05")
9999

100100
# Test error handling for values exceeding the modulus
@@ -172,3 +172,125 @@ def test_serialize_list_roundtrip_property() -> None:
172172

173173
assert recovered == elements
174174
assert len(data) == count * 4
175+
176+
177+
def test_ssz_type_properties() -> None:
178+
"""Test that Fp correctly implements SSZ type interface."""
179+
# Test is_fixed_size
180+
assert Fp.is_fixed_size() is True
181+
182+
# Test get_byte_length
183+
assert Fp.get_byte_length() == 4
184+
185+
186+
def test_ssz_serialize() -> None:
187+
"""Test SSZ serialization using the serialize method."""
188+
import io
189+
190+
fp = Fp(value=42)
191+
192+
# Test serialize to stream
193+
stream = io.BytesIO()
194+
bytes_written = fp.serialize(stream)
195+
assert bytes_written == 4
196+
assert stream.getvalue() == b"\x2a\x00\x00\x00" # 42 in LE
197+
198+
199+
def test_ssz_deserialize() -> None:
200+
"""Test SSZ deserialization using the deserialize method."""
201+
import io
202+
203+
# Test successful deserialization
204+
data = b"\x2a\x00\x00\x00" # 42 in LE
205+
stream = io.BytesIO(data)
206+
fp = Fp.deserialize(stream, 4)
207+
assert fp == Fp(value=42)
208+
209+
210+
def test_ssz_deserialize_wrong_scope() -> None:
211+
"""Test deserialize error when scope doesn't match P_BYTES."""
212+
import io
213+
214+
data = b"\x2a\x00\x00\x00"
215+
stream = io.BytesIO(data)
216+
with pytest.raises(ValueError, match="Expected 4 bytes for Fp, got 3"):
217+
Fp.deserialize(stream, 3)
218+
219+
220+
def test_ssz_deserialize_short_data() -> None:
221+
"""Test deserialize error when stream has insufficient data."""
222+
import io
223+
224+
stream = io.BytesIO(b"\x01\x02\x03") # Only 3 bytes
225+
with pytest.raises(ValueError, match="Expected 4 bytes for Fp, got 3"):
226+
Fp.deserialize(stream, 4)
227+
228+
229+
def test_ssz_deserialize_exceeds_modulus() -> None:
230+
"""Test deserialize error when value exceeds field modulus."""
231+
import io
232+
233+
# P = 2^31 - 2^24 + 1 = 2130706433
234+
# Encode a value >= P (use P itself)
235+
invalid_data = P.to_bytes(4, byteorder="little")
236+
stream = io.BytesIO(invalid_data)
237+
with pytest.raises(ValueError, match="exceeds field modulus"):
238+
Fp.deserialize(stream, 4)
239+
240+
241+
def test_ssz_encode_decode_bytes() -> None:
242+
"""Test SSZ encode_bytes and decode_bytes methods."""
243+
# Test encode_bytes
244+
fp = Fp(value=100)
245+
data = fp.encode_bytes()
246+
assert len(data) == 4
247+
assert data == b"\x64\x00\x00\x00" # 100 in LE
248+
249+
# Test decode_bytes
250+
fp2 = Fp.decode_bytes(data)
251+
assert fp2 == fp
252+
253+
# Test roundtrip for various values
254+
test_values = [0, 1, 42, 255, 256, 1000, 65535, 65536, 1000000, P - 1]
255+
for value in test_values:
256+
fp = Fp(value=value)
257+
data = fp.encode_bytes()
258+
recovered = Fp.decode_bytes(data)
259+
assert recovered == fp, f"Failed for value={value}"
260+
261+
262+
def test_ssz_roundtrip() -> None:
263+
"""Comprehensive SSZ roundtrip test with many values."""
264+
import random
265+
266+
random.seed(12345)
267+
268+
for _ in range(100):
269+
# Test with random values
270+
value = random.randint(0, P - 1)
271+
fp = Fp(value=value)
272+
273+
# Test all serialization methods give same result
274+
data1 = bytes(fp)
275+
data2 = fp.encode_bytes()
276+
assert data1 == data2
277+
278+
# Test all deserialization methods work
279+
recovered1 = Fp.from_bytes(data1)
280+
recovered2 = Fp.decode_bytes(data2)
281+
assert recovered1 == fp
282+
assert recovered2 == fp
283+
284+
285+
def test_ssz_deterministic() -> None:
286+
"""Test that SSZ serialization is deterministic."""
287+
fp = Fp(value=999)
288+
289+
# Serialize multiple times
290+
data1 = fp.encode_bytes()
291+
data2 = fp.encode_bytes()
292+
data3 = bytes(fp)
293+
294+
# All should be identical
295+
assert data1 == data2
296+
assert data1 == data3

0 commit comments

Comments
 (0)