Skip to content

Commit 6011d37

Browse files
committed
feat: add SSZ static vector generator
1 parent b81a658 commit 6011d37

7 files changed

Lines changed: 357 additions & 18 deletions

File tree

packages/testing/src/execution_testing/ssz/__init__.py

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22
SSZ container types and helpers for the REST+SSZ Engine API.
33
"""
44

5-
from typing import Any, Mapping, Sequence, Type, TypeVar
5+
from typing import Any, Mapping, Sequence
66

77
from remerkleable.core import View
88

9+
from .codec import (
10+
decode_bytes,
11+
decode_value,
12+
encode_bytes,
13+
encode_value,
14+
hash_tree_root,
15+
)
916
from .constants import (
1017
MAX_BYTES_PER_EXECUTION_REQUEST,
1118
MAX_BYTES_PER_TX,
@@ -96,23 +103,6 @@
96103
VersionedHash,
97104
)
98105

99-
ViewT = TypeVar("ViewT", bound=View)
100-
101-
102-
def encode_bytes(value: View) -> bytes:
103-
"""Serialize an SSZ value to its canonical byte encoding."""
104-
return value.encode_bytes()
105-
106-
107-
def decode_bytes(ssz_type: Type[ViewT], data: bytes) -> ViewT:
108-
"""Deserialize ``data`` into an SSZ value of ``ssz_type``."""
109-
return ssz_type.decode_bytes(data)
110-
111-
112-
def hash_tree_root(value: View) -> bytes:
113-
"""Return the 32-byte SSZ `hash_tree_root` of an SSZ value."""
114-
return bytes(value.hash_tree_root())
115-
116106

117107
def _build(cls: Any, fork: str, candidates: Mapping[str, Any]) -> View:
118108
kwargs = {}
@@ -284,8 +274,10 @@ def _opt_bytes(value: bytes | None) -> bytes | None:
284274
"VersionedHash",
285275
"Withdrawal",
286276
"decode_bytes",
277+
"decode_value",
287278
"deterministic_seed",
288279
"encode_bytes",
280+
"encode_value",
289281
"envelope_bytes",
290282
"get_random_ssz_object",
291283
"hash_tree_root",
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
SSZ (de)serialization for this package's containers.
3+
"""
4+
5+
from typing import Any, Type, TypeVar
6+
7+
from remerkleable.basic import boolean, uint
8+
from remerkleable.bitfields import Bitlist, Bitvector
9+
from remerkleable.byte_arrays import ByteList, ByteVector
10+
from remerkleable.complex import Container, List
11+
from remerkleable.core import View
12+
13+
ViewT = TypeVar("ViewT", bound=View)
14+
15+
16+
def encode_bytes(value: View) -> bytes:
17+
"""Serialize an SSZ value to its canonical byte encoding."""
18+
return value.encode_bytes()
19+
20+
21+
def decode_bytes(ssz_type: Type[ViewT], data: bytes) -> ViewT:
22+
"""Deserialize ``data`` into an SSZ value of ``ssz_type``."""
23+
return ssz_type.decode_bytes(data)
24+
25+
26+
def hash_tree_root(value: View) -> bytes:
27+
"""Return the 32-byte SSZ `hash_tree_root` of an SSZ value."""
28+
return bytes(value.hash_tree_root())
29+
30+
31+
def encode_value(value: Any) -> Any:
32+
"""
33+
Encode an SSZ value to its canonical value tree.
34+
"""
35+
if isinstance(value, Container):
36+
return {
37+
field: encode_value(getattr(value, field))
38+
for field in type(value).fields()
39+
}
40+
if isinstance(value, (Bitvector, Bitlist)):
41+
return "0x" + value.encode_bytes().hex()
42+
if isinstance(value, List):
43+
return [encode_value(element) for element in value]
44+
if isinstance(value, (ByteVector, ByteList)):
45+
return "0x" + bytes(value).hex()
46+
if isinstance(value, boolean):
47+
return bool(value)
48+
if isinstance(value, uint):
49+
return str(int(value))
50+
raise TypeError(f"unsupported SSZ value type: {type(value)!r}")
51+
52+
53+
def decode_value(ssz_type: Any, obj: Any) -> Any:
54+
"""
55+
Decode a canonical value tree into an SSZ value of ``ssz_type``.
56+
"""
57+
if issubclass(ssz_type, Container):
58+
fields = ssz_type.fields()
59+
return ssz_type(
60+
**{
61+
field: decode_value(field_type, obj[field])
62+
for field, field_type in fields.items()
63+
}
64+
)
65+
if issubclass(ssz_type, (Bitvector, Bitlist)):
66+
return ssz_type.decode_bytes(bytes.fromhex(_strip_0x(obj)))
67+
if issubclass(ssz_type, List):
68+
element_type = ssz_type.element_cls()
69+
return ssz_type(*(decode_value(element_type, item) for item in obj))
70+
if issubclass(ssz_type, (ByteVector, ByteList)):
71+
return ssz_type(bytes.fromhex(_strip_0x(obj)))
72+
if issubclass(ssz_type, boolean):
73+
return ssz_type(bool(obj))
74+
if issubclass(ssz_type, uint):
75+
return ssz_type(int(obj))
76+
raise TypeError(f"unsupported SSZ type: {ssz_type!r}")
77+
78+
79+
def _strip_0x(value: str) -> str:
80+
"""Return ``value`` without a leading ``0x`` prefix."""
81+
return value[2:] if value.startswith("0x") else value

packages/testing/src/execution_testing/ssz/random_value.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import Any
99

1010
from remerkleable.basic import boolean, uint
11+
from remerkleable.bitfields import Bitlist, Bitvector
1112
from remerkleable.byte_arrays import ByteList, ByteVector
1213
from remerkleable.complex import Container, List
1314
from remerkleable.core import View
@@ -109,6 +110,36 @@ def get_random_ssz_object(
109110
return _max_basic_value(typ)
110111
else:
111112
return _random_basic_value(rng, typ)
113+
elif issubclass(typ, Bitvector):
114+
# Bit vectors are fixed length; no cap applies.
115+
length = typ.vector_length()
116+
if mode == RandomizationMode.mode_zero:
117+
return typ(*([False] * length))
118+
elif mode == RandomizationMode.mode_max:
119+
return typ(*([True] * length))
120+
else:
121+
return typ(*(bool(rng.getrandbits(1)) for _ in range(length)))
122+
elif issubclass(typ, Bitlist):
123+
cap = min(max_bytes_length, typ.limit())
124+
if mode == RandomizationMode.mode_nil_count:
125+
length = 0
126+
elif mode == RandomizationMode.mode_max_count:
127+
length = cap
128+
elif mode in (
129+
RandomizationMode.mode_one_count,
130+
RandomizationMode.mode_zero,
131+
RandomizationMode.mode_max,
132+
):
133+
length = min(1, typ.limit())
134+
else:
135+
length = rng.randint(0, cap)
136+
if mode == RandomizationMode.mode_zero:
137+
bits = [False] * length
138+
elif mode == RandomizationMode.mode_max:
139+
bits = [True] * length
140+
else:
141+
bits = [bool(rng.getrandbits(1)) for _ in range(length)]
142+
return typ(*bits)
112143
elif issubclass(typ, List):
113144
limit = max_list_length
114145
if typ.limit() < limit:
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""
2+
Self-tests for the SSZ vector generator.
3+
"""
4+
5+
from pathlib import Path
6+
7+
import yaml
8+
9+
from .. import decode_bytes, decode_value, encode_bytes, hash_tree_root
10+
from ..vectors.generate import (
11+
CONTAINERS,
12+
case_dir,
13+
iter_cases,
14+
write_case,
15+
)
16+
17+
CASES = list(iter_cases())
18+
19+
20+
def test_generator_produces_cases() -> None:
21+
"""Sanity: the generator actually produces cases."""
22+
assert CASES
23+
24+
25+
def test_generation_is_deterministic() -> None:
26+
"""Two independent generations are identical (seeded determinism)."""
27+
again = [case for _, _, _, case in iter_cases()]
28+
assert again == [case for _, _, _, case in CASES]
29+
30+
31+
def test_each_case_is_internally_consistent() -> None:
32+
"""
33+
``serialized`` decodes and re-encodes to itself, its ``hash_tree_root``
34+
equals ``root``, and the canonical ``value`` parses back to the same
35+
bytes. This is a self-test of the generator, not a check against an
36+
independent oracle.
37+
"""
38+
for container_name, _, _, case in CASES:
39+
ssz_type = CONTAINERS[container_name]
40+
serialized = case["serialized"]
41+
decoded = decode_bytes(ssz_type, serialized)
42+
assert encode_bytes(decoded) == serialized, container_name
43+
assert "0x" + hash_tree_root(decoded).hex() == case["root"]
44+
from_value = decode_value(ssz_type, case["value"])
45+
assert encode_bytes(from_value) == serialized
46+
47+
48+
def test_case_has_expected_keys() -> None:
49+
"""Each generated case carries exactly the documented keys."""
50+
for _, _, _, case in CASES:
51+
assert set(case) == {"value", "serialized", "root"}
52+
53+
54+
def test_write_case_emits_three_files(tmp_path: Path) -> None:
55+
"""``write_case`` writes the ssz_static three-file layout, uncompressed."""
56+
container_name, suite, case_index, case = CASES[0]
57+
directory = case_dir(tmp_path, container_name, suite, case_index)
58+
write_case(directory, case)
59+
60+
assert (directory / "serialized.ssz").read_bytes() == case["serialized"]
61+
assert yaml.safe_load((directory / "value.yaml").read_text()) == (
62+
case["value"]
63+
)
64+
assert yaml.safe_load((directory / "roots.yaml").read_text()) == {
65+
"root": case["root"]
66+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Generated SSZ vectors are a release artifact, never committed (see README.md).
2+
**/case_*/
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Cross-client SSZ static test vectors and their generator."""

0 commit comments

Comments
 (0)