Skip to content

Commit 26ad6a0

Browse files
marioevzspencer-tb
andauthored
refactor(base_types,types,tests): Create RLP Serialization Classes (#1359)
* fix(types): tests: Add serialization unit tests * fix(types): tests: Fix unit tests * feat(base_types): Implement serialization RLP types * feat(base_types,types,fixtures): Apply serialization RLP types * fix(types): Auth tuple defaults * fix(fixtures,rpc,specs,types,tests): Transaction.rlp is a function * fix(base_types,types,rpc): fixes * refactor(base_types,types): Network wrapped transaction * fix(tests): Network wrapped transactions * fix(types): Fix NetworkWrappedTransaction rlp prefix * docs: Changelog * Apply suggestions from code review Co-authored-by: spencer <[email protected]> * Add network wrapper explanation * fix: tox * Update docs/CHANGELOG.md Co-authored-by: spencer <[email protected]> * Add comment --------- Co-authored-by: spencer <[email protected]>
1 parent 0cb595c commit 26ad6a0

File tree

18 files changed

+584
-296
lines changed

18 files changed

+584
-296
lines changed

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ consume cache --help
5252

5353
- 🔀 Bump the version of `execution-specs` used by the framework to the package [`ethereum-execution==1.17.0rc6.dev1`](https://pypi.org/project/ethereum-execution/1.17.0rc6.dev1/); bump the version used for test fixture generation for forks < Prague to current `execution-specs` master, [fa847a0](https://github.com/ethereum/execution-specs/commit/fa847a0e48309debee8edc510ceddb2fd5db2f2e) ([#1310](https://github.com/ethereum/execution-spec-tests/pull/1310)).
5454
- 🐞 Init `TransitionTool` in `GethTransitionTool` ([#1276](https://github.com/ethereum/execution-spec-tests/pull/1276)).
55+
- 🔀 Refactored RLP encoding of test objects to allow automatic generation of tests ([#1359](https://github.com/ethereum/execution-spec-tests/pull/1359)).
5556

5657
#### Packaging
5758

src/ethereum_test_base_types/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from .json import to_json
4040
from .pydantic import CamelModel, EthereumTestBaseModel, EthereumTestRootModel
4141
from .reference_spec import ReferenceSpec
42+
from .serialization import RLPSerializable, SignableRLPSerializable
4243

4344
__all__ = (
4445
"AccessList",
@@ -66,6 +67,8 @@
6667
"Number",
6768
"NumberBoundTypeVar",
6869
"ReferenceSpec",
70+
"RLPSerializable",
71+
"SignableRLPSerializable",
6972
"Storage",
7073
"StorageRootType",
7174
"TestAddress",

src/ethereum_test_base_types/composite_types.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .base_types import Address, Bytes, Hash, HashInt, HexNumber, ZeroPaddedHexNumber
99
from .conversions import BytesConvertible, NumberConvertible
1010
from .pydantic import CamelModel, EthereumTestRootModel
11+
from .serialization import RLPSerializable
1112

1213
StorageKeyValueTypeConvertible = NumberConvertible
1314
StorageKeyValueType = HashInt
@@ -460,15 +461,13 @@ class Alloc(EthereumTestRootModel[Dict[Address, Account | None]]):
460461
root: Dict[Address, Account | None] = Field(default_factory=dict, validate_default=True)
461462

462463

463-
class AccessList(CamelModel):
464+
class AccessList(CamelModel, RLPSerializable):
464465
"""Access List for transactions."""
465466

466467
address: Address
467468
storage_keys: List[Hash]
468469

469-
def to_list(self) -> List[Address | List[Hash]]:
470-
"""Return access list as a list of serializable elements."""
471-
return [self.address, self.storage_keys]
470+
rlp_fields: ClassVar[List[str]] = ["address", "storage_keys"]
472471

473472

474473
class ForkBlobSchedule(CamelModel):
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Ethereum test types for serialization and encoding."""
2+
3+
from typing import Any, ClassVar, List
4+
5+
import ethereum_rlp as eth_rlp
6+
from ethereum_types.numeric import Uint
7+
8+
from ethereum_test_base_types import Bytes
9+
10+
11+
def to_serializable_element(v: Any) -> Any:
12+
"""Return a serializable element that can be passed to `eth_rlp.encode`."""
13+
if isinstance(v, int):
14+
return Uint(v)
15+
elif isinstance(v, bytes):
16+
return v
17+
elif isinstance(v, list):
18+
return [to_serializable_element(v) for v in v]
19+
elif isinstance(v, RLPSerializable):
20+
if v.signable:
21+
v.sign()
22+
return v.to_list(signing=False)
23+
elif v is None:
24+
return b""
25+
raise Exception(f"Unable to serialize element {v} of type {type(v)}.")
26+
27+
28+
class RLPSerializable:
29+
"""Class that adds RLP serialization to another class."""
30+
31+
rlp_override: Bytes | None = None
32+
33+
signable: ClassVar[bool] = False
34+
rlp_fields: ClassVar[List[str]]
35+
rlp_signing_fields: ClassVar[List[str]]
36+
37+
def get_rlp_fields(self) -> List[str]:
38+
"""
39+
Return an ordered list of field names to be included in RLP serialization.
40+
41+
Function can be overridden to customize the logic to return the fields.
42+
43+
By default, rlp_fields class variable is used.
44+
45+
The list can be nested list up to one extra level to represent nested fields.
46+
"""
47+
return self.rlp_fields
48+
49+
def get_rlp_signing_fields(self) -> List[str]:
50+
"""
51+
Return an ordered list of field names to be included in the RLP serialization of the object
52+
signature.
53+
54+
Function can be overridden to customize the logic to return the fields.
55+
56+
By default, rlp_signing_fields class variable is used.
57+
58+
The list can be nested list up to one extra level to represent nested fields.
59+
"""
60+
return self.rlp_signing_fields
61+
62+
def get_rlp_prefix(self) -> bytes:
63+
"""
64+
Return a prefix that has to be appended to the serialized object.
65+
66+
By default, an empty string is returned.
67+
"""
68+
return b""
69+
70+
def get_rlp_signing_prefix(self) -> bytes:
71+
"""
72+
Return a prefix that has to be appended to the serialized signing object.
73+
74+
By default, an empty string is returned.
75+
"""
76+
return b""
77+
78+
def sign(self):
79+
"""Sign the current object for further serialization."""
80+
raise NotImplementedError(f'Object "{self.__class__.__name__}" cannot be signed.')
81+
82+
def to_list_from_fields(self, fields: List[str]) -> List[Any]:
83+
"""
84+
Return an RLP serializable list that can be passed to `eth_rlp.encode`.
85+
86+
Can be for signing purposes or the entire object.
87+
"""
88+
values_list: List[Any] = []
89+
for field in fields:
90+
assert isinstance(field, str), (
91+
f'Unable to rlp serialize field "{field}" '
92+
f'in object type "{self.__class__.__name__}"'
93+
)
94+
assert hasattr(self, field), (
95+
f'Unable to rlp serialize field "{field}" '
96+
f'in object type "{self.__class__.__name__}"'
97+
)
98+
try:
99+
values_list.append(to_serializable_element(getattr(self, field)))
100+
except Exception as e:
101+
raise Exception(
102+
f'Unable to rlp serialize field "{field}" '
103+
f'in object type "{self.__class__.__name__}"'
104+
) from e
105+
return values_list
106+
107+
def to_list(self, signing: bool = False) -> List[Any]:
108+
"""
109+
Return an RLP serializable list that can be passed to `eth_rlp.encode`.
110+
111+
Can be for signing purposes or the entire object.
112+
"""
113+
field_list: List[str]
114+
if signing:
115+
if not self.signable:
116+
raise Exception(f'Object "{self.__class__.__name__}" does not support signing')
117+
field_list = self.get_rlp_signing_fields()
118+
else:
119+
if self.signable:
120+
# Automatically sign signable objects during full serialization:
121+
# Ensures nested objects have valid signatures in the final RLP.
122+
self.sign()
123+
field_list = self.get_rlp_fields()
124+
125+
return self.to_list_from_fields(field_list)
126+
127+
def rlp_signing_bytes(self) -> Bytes:
128+
"""Return the signing serialized envelope used for signing."""
129+
return Bytes(self.get_rlp_signing_prefix() + eth_rlp.encode(self.to_list(signing=True)))
130+
131+
def rlp(self) -> Bytes:
132+
"""Return the serialized object."""
133+
if self.rlp_override is not None:
134+
return self.rlp_override
135+
return Bytes(self.get_rlp_prefix() + eth_rlp.encode(self.to_list(signing=False)))
136+
137+
138+
class SignableRLPSerializable(RLPSerializable):
139+
"""Class that adds RLP serialization to another class with signing support."""
140+
141+
signable: ClassVar[bool] = True
142+
143+
def sign(self):
144+
"""Sign the current object for further serialization."""
145+
raise NotImplementedError(f'Object "{self.__class__.__name__}" needs to implement `sign`.')

src/ethereum_test_fixtures/blockchain.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ def from_fixture_header(
214214
"""
215215
return cls(
216216
**header.model_dump(exclude={"rlp"}, exclude_none=True),
217-
transactions=[tx.rlp for tx in transactions],
217+
transactions=[tx.rlp() for tx in transactions],
218218
withdrawals=withdrawals,
219219
)
220220

src/ethereum_test_fixtures/common.py

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

33
from typing import Dict
44

5-
from pydantic import Field, model_serializer
5+
from pydantic import AliasChoices, Field, model_serializer
66

77
from ethereum_test_base_types import (
88
BlobSchedule,
99
CamelModel,
1010
EthereumTestRootModel,
11+
SignableRLPSerializable,
1112
ZeroPaddedHexNumber,
1213
)
1314
from ethereum_test_types.types import Address, AuthorizationTupleGeneric
@@ -38,9 +39,15 @@ def from_blob_schedule(
3839
)
3940

4041

41-
class FixtureAuthorizationTuple(AuthorizationTupleGeneric[ZeroPaddedHexNumber]):
42+
class FixtureAuthorizationTuple(
43+
AuthorizationTupleGeneric[ZeroPaddedHexNumber], SignableRLPSerializable
44+
):
4245
"""Authorization tuple for fixture transactions."""
4346

47+
v: ZeroPaddedHexNumber = Field(validation_alias=AliasChoices("v", "yParity")) # type: ignore
48+
r: ZeroPaddedHexNumber
49+
s: ZeroPaddedHexNumber
50+
4451
signer: Address | None = None
4552

4653
@classmethod
@@ -61,3 +68,8 @@ def duplicate_v_as_y_parity(self, serializer):
6168
if "v" in data and data["v"] is not None:
6269
data["yParity"] = data["v"]
6370
return data
71+
72+
def sign(self):
73+
"""Sign the current object for further serialization."""
74+
# No-op, as the object is always already signed
75+
return

src/ethereum_test_fixtures/tests/test_blockchain.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,8 @@
633633
blob_versioned_hashes=[0, 1],
634634
)
635635
.with_signature_and_sender()
636-
.rlp.hex()
636+
.rlp()
637+
.hex()
637638
],
638639
"withdrawals": [
639640
to_json(Withdrawal(index=0, validator_index=1, address=0x1234, amount=2))
@@ -746,7 +747,8 @@
746747
blob_versioned_hashes=[0, 1],
747748
)
748749
.with_signature_and_sender()
749-
.rlp.hex()
750+
.rlp()
751+
.hex()
750752
],
751753
"withdrawals": [
752754
to_json(
@@ -900,7 +902,8 @@
900902
blob_versioned_hashes=[0, 1],
901903
)
902904
.with_signature_and_sender()
903-
.rlp.hex()
905+
.rlp()
906+
.hex()
904907
],
905908
"withdrawals": [
906909
to_json(
@@ -1054,7 +1057,8 @@ def test_json_deserialization(
10541057
blob_versioned_hashes=[0, 1],
10551058
)
10561059
.with_signature_and_sender()
1057-
.rlp.hex()
1060+
.rlp()
1061+
.hex()
10581062
],
10591063
"withdrawals": [
10601064
to_json(Withdrawal(index=0, validator_index=1, address=0x1234, amount=2))
@@ -1144,7 +1148,8 @@ def test_json_deserialization(
11441148
blob_versioned_hashes=[0, 1],
11451149
)
11461150
.with_signature_and_sender()
1147-
.rlp.hex()
1151+
.rlp()
1152+
.hex()
11481153
],
11491154
"withdrawals": [
11501155
to_json(Withdrawal(index=0, validator_index=1, address=0x1234, amount=2))
@@ -1255,7 +1260,8 @@ def test_json_deserialization(
12551260
blob_versioned_hashes=[0, 1],
12561261
)
12571262
.with_signature_and_sender()
1258-
.rlp.hex()
1263+
.rlp()
1264+
.hex()
12591265
],
12601266
"withdrawals": [
12611267
to_json(Withdrawal(index=0, validator_index=1, address=0x1234, amount=2))

src/ethereum_test_rpc/rpc.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ def send_raw_transaction(self, transaction_rlp: Bytes) -> Hash:
170170
def send_transaction(self, transaction: Transaction) -> Hash:
171171
"""`eth_sendRawTransaction`: Send a transaction to the client."""
172172
try:
173-
result_hash = Hash(self.post_request("sendRawTransaction", f"{transaction.rlp.hex()}"))
173+
result_hash = Hash(
174+
self.post_request("sendRawTransaction", f"{transaction.rlp().hex()}")
175+
)
174176
assert result_hash == transaction.hash
175177
assert result_hash is not None
176178
return transaction.hash

src/ethereum_test_rpc/types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class TransactionByHashResponse(Transaction):
3737
from_address: Address = Field(..., alias="from")
3838
to_address: Address | None = Field(..., alias="to")
3939

40-
v: HexNumber | None = Field(None, validation_alias=AliasChoices("v", "yParity"))
40+
v: HexNumber = Field(0, validation_alias=AliasChoices("v", "yParity")) # type: ignore
4141

4242
@model_validator(mode="before")
4343
@classmethod

src/ethereum_test_specs/state.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ def make_state_test_fixture(
198198
FixtureForkPost(
199199
state_root=transition_tool_output.result.state_root,
200200
logs_hash=transition_tool_output.result.logs_hash,
201-
tx_bytes=tx.rlp,
201+
tx_bytes=tx.rlp(),
202202
expect_exception=tx.error,
203203
state=transition_tool_output.alloc,
204204
)

0 commit comments

Comments
 (0)