Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 31273b7

Browse files
committedNov 27, 2024·
json schema verification: verify block headers in generated blockchain tests
1 parent 73f8cd5 commit 31273b7

File tree

6 files changed

+349
-2
lines changed

6 files changed

+349
-2
lines changed
 

‎src/ethereum_test_fixtures/file.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .blockchain import Fixture as BlockchainFixture
1414
from .eof import Fixture as EOFFixture
1515
from .state import Fixture as StateFixture
16+
from .verify_format import VerifyFixtureJson
1617

1718
FixtureModel = BlockchainFixture | BlockchainEngineFixture | StateFixture | EOFFixture
1819

@@ -64,7 +65,9 @@ def collect_into_file(self, file_path: Path):
6465
"""
6566
json_fixtures: Dict[str, Dict[str, Any]] = {}
6667
for name, fixture in self.items():
67-
json_fixtures[name] = fixture.json_dict_with_info()
68+
fixture_json = fixture.json_dict_with_info()
69+
VerifyFixtureJson(name, fixture_json)
70+
json_fixtures[name] = fixture_json
6871
with open(file_path, "w") as f:
6972
json.dump(json_fixtures, f, indent=4)
7073

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
Define genesisHeader schema for filled .json tests
3+
"""
4+
5+
from pydantic import BaseModel, model_validator
6+
7+
from ..common.types import (
8+
DataBytes,
9+
FixedHash8,
10+
FixedHash20,
11+
FixedHash32,
12+
FixedHash256,
13+
PrefixedEvenHex,
14+
)
15+
16+
17+
class BlockRecord(BaseModel):
18+
"""Block record in blockchain tests"""
19+
20+
blockHeader: dict # noqa: N815
21+
rlp: str
22+
transactions: list # noqa: N815
23+
uncleHeaders: list # noqa: N815
24+
25+
26+
class FrontierHeader(BaseModel):
27+
"""Frontier block header in test json"""
28+
29+
bloom: FixedHash256
30+
coinbase: FixedHash20
31+
difficulty: PrefixedEvenHex
32+
extraData: DataBytes # noqa: N815"
33+
gasLimit: PrefixedEvenHex # noqa: N815"
34+
gasUsed: PrefixedEvenHex # noqa: N815"
35+
hash: FixedHash32
36+
mixHash: FixedHash32 # noqa: N815"
37+
nonce: FixedHash8
38+
number: PrefixedEvenHex
39+
parentHash: FixedHash32 # noqa: N815"
40+
receiptTrie: FixedHash32 # noqa: N815"
41+
stateRoot: FixedHash32 # noqa: N815"
42+
timestamp: PrefixedEvenHex
43+
transactionsTrie: FixedHash32 # noqa: N815"
44+
uncleHash: FixedHash32 # noqa: N815"
45+
46+
class Config:
47+
"""Forbids any extra fields that are not declared in the model"""
48+
49+
extra = "forbid"
50+
51+
52+
class HomesteadHeader(FrontierHeader):
53+
"""Homestead block header in test json"""
54+
55+
56+
class ByzantiumHeader(HomesteadHeader):
57+
"""Byzantium block header in test json"""
58+
59+
60+
class ConstantinopleHeader(ByzantiumHeader):
61+
"""Constantinople block header in test json"""
62+
63+
64+
class IstanbulHeader(ConstantinopleHeader):
65+
"""Istanbul block header in test json"""
66+
67+
68+
class BerlinHeader(IstanbulHeader):
69+
"""Berlin block header in test json"""
70+
71+
72+
class LondonHeader(BerlinHeader):
73+
"""London block header in test json"""
74+
75+
baseFeePerGas: PrefixedEvenHex # noqa: N815
76+
77+
78+
class ParisHeader(LondonHeader):
79+
"""Paris block header in test json"""
80+
81+
@model_validator(mode="after")
82+
def check_block_header(self):
83+
"""
84+
Validate Paris block header rules
85+
"""
86+
87+
if self.difficulty != "0x00":
88+
raise ValueError("Starting from Paris, block difficulty must be 0x00")
89+
90+
91+
class ShanghaiHeader(ParisHeader):
92+
"""Shanghai block header in test json"""
93+
94+
withdrawalsRoot: FixedHash32 # noqa: N815
95+
96+
97+
class CancunHeader(ShanghaiHeader):
98+
"""Cancun block header in test json"""
99+
100+
blobGasUsed: PrefixedEvenHex # noqa: N815
101+
excessBlobGas: PrefixedEvenHex # noqa: N815
102+
parentBeaconBlockRoot: FixedHash32 # noqa: N815
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
Schema for filled Blockchain Test
3+
"""
4+
5+
from pydantic import BaseModel, Field, model_validator
6+
7+
from .genesis import (
8+
BerlinHeader,
9+
BlockRecord,
10+
ByzantiumHeader,
11+
CancunHeader,
12+
ConstantinopleHeader,
13+
FrontierHeader,
14+
HomesteadHeader,
15+
IstanbulHeader,
16+
LondonHeader,
17+
ParisHeader,
18+
ShanghaiHeader,
19+
)
20+
21+
22+
class BlockchainTestFixtureModel(BaseModel):
23+
"""
24+
Blockchain test file
25+
"""
26+
27+
info: dict = Field(alias="_info")
28+
network: str
29+
genesisBlockHeader: dict # noqa: N815
30+
pre: dict
31+
postState: dict # noqa: N815
32+
lastblockhash: str
33+
genesisRLP: str # noqa: N815
34+
blocks: list[BlockRecord]
35+
sealEngine: str # noqa: N815
36+
37+
class Config:
38+
"""Forbids any extra fields that are not declared in the model"""
39+
40+
extra = "forbid"
41+
42+
@model_validator(mode="after")
43+
def check_block_headers(self):
44+
"""
45+
Validate genesis header fields based by fork
46+
"""
47+
# TODO str to Fork class comparison
48+
allowed_networks = {
49+
"Frontier": FrontierHeader,
50+
"Homestead": HomesteadHeader,
51+
"EIP150": HomesteadHeader,
52+
"EIP158": HomesteadHeader,
53+
"Byzantium": ByzantiumHeader,
54+
"Constantinople": ConstantinopleHeader,
55+
"ConstantinopleFix": ConstantinopleHeader,
56+
"Istanbul": IstanbulHeader,
57+
"Berlin": BerlinHeader,
58+
"London": LondonHeader,
59+
"Paris": ParisHeader,
60+
"Shanghai": ShanghaiHeader,
61+
"Cancun": CancunHeader,
62+
}
63+
64+
header_class = allowed_networks.get(self.network)
65+
if not header_class:
66+
raise ValueError("Incorrect value in network field: " + self.network)
67+
header_class(**self.genesisBlockHeader)
68+
for block in self.blocks:
69+
header_class(**block.blockHeader)
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""
2+
Base types for Pydantic json test fixtures
3+
"""
4+
5+
import re
6+
from typing import Generic, TypeVar
7+
8+
from pydantic import RootModel, model_validator
9+
10+
T = TypeVar("T", bound="FixedHash")
11+
12+
13+
class FixedHash(RootModel[str], Generic[T]):
14+
"""Base class for fixed-length hashes."""
15+
16+
_length_in_bytes: int
17+
18+
@model_validator(mode="after")
19+
def validate_hex_hash(self):
20+
"""
21+
Validate that the field is a 0x-prefixed hash of specified byte length.
22+
"""
23+
expected_length = 2 + 2 * self._length_in_bytes # 2 for '0x' + 2 hex chars per byte
24+
if not self.root.startswith("0x"):
25+
raise ValueError("The hash must start with '0x'.")
26+
if len(self.root) != expected_length:
27+
raise ValueError(
28+
f"The hash must be {expected_length} characters long "
29+
f"(2 for '0x' and {2 * self._length_in_bytes} hex characters)."
30+
)
31+
if not re.fullmatch(rf"0x[a-fA-F0-9]{{{2 * self._length_in_bytes}}}", self.root):
32+
raise ValueError(
33+
f"The hash must be a valid hexadecimal string of "
34+
f"{2 * self._length_in_bytes} characters after '0x'."
35+
)
36+
37+
38+
class FixedHash32(FixedHash):
39+
"""FixedHash32 type (32 bytes)"""
40+
41+
_length_in_bytes = 32
42+
43+
44+
class FixedHash20(FixedHash):
45+
"""FixedHash20 type (20 bytes)"""
46+
47+
_length_in_bytes = 20
48+
49+
50+
class FixedHash8(FixedHash):
51+
"""FixedHash8 type (8 bytes)"""
52+
53+
_length_in_bytes = 8
54+
55+
56+
class FixedHash256(FixedHash):
57+
"""FixedHash256 type (256 bytes)"""
58+
59+
_length_in_bytes = 256
60+
61+
62+
class PrefixedEvenHex(RootModel[str]):
63+
"""Class to validate a hexadecimal integer encoding in test files."""
64+
65+
def __eq__(self, other):
66+
"""
67+
For python str comparison
68+
"""
69+
if isinstance(other, str):
70+
return self.root == other
71+
return NotImplemented
72+
73+
@model_validator(mode="after")
74+
def validate_hex_integer(self):
75+
"""
76+
Validate that the field is a hexadecimal integer with specific rules:
77+
- Must start with '0x'.
78+
- Must be even in length after '0x'.
79+
- Cannot be '0x0', '0x0000', etc. (minimum is '0x00').
80+
"""
81+
# Ensure it starts with '0x'
82+
if not self.root.startswith("0x"):
83+
raise ValueError("The value must start with '0x'.")
84+
85+
# Extract the hex portion (after '0x')
86+
hex_part = self.root[2:]
87+
88+
# Ensure the length of the hex part is even
89+
if len(hex_part) % 2 != 0:
90+
raise ValueError(
91+
"The hexadecimal value must have an even number of characters after '0x'."
92+
)
93+
94+
# Special rule: Only '0x00' is allowed; disallow '0x0000', '0x0001', etc.
95+
if hex_part.startswith("00") and hex_part != "00":
96+
raise ValueError("Leading zeros are not allowed except for '0x00'.")
97+
98+
# Ensure it's a valid hexadecimal string
99+
if not re.fullmatch(r"[a-fA-F0-9]+", hex_part):
100+
raise ValueError("The value must be a valid hexadecimal string.")
101+
102+
return self
103+
104+
105+
class DataBytes(RootModel[str]):
106+
"""Class to validate DataBytes."""
107+
108+
@model_validator(mode="after")
109+
def validate_data_bytes(self):
110+
"""
111+
Validate that the field follows the rules for DataBytes:
112+
- Must start with '0x'.
113+
- Can be empty (just '0x').
114+
- Must be even in length after '0x'.
115+
- Allows prefixed '00' values (e.g., '0x00000001').
116+
- Must be a valid hexadecimal string.
117+
"""
118+
# Ensure it starts with '0x'
119+
if not self.root.startswith("0x"):
120+
raise ValueError("The value must start with '0x'.")
121+
122+
# Extract the hex portion (after '0x')
123+
hex_part = self.root[2:]
124+
125+
# Allow empty '0x'
126+
if len(hex_part) == 0:
127+
return self
128+
129+
# Ensure the length of the hex part is even
130+
if len(hex_part) % 2 != 0:
131+
raise ValueError(
132+
"The hexadecimal value must have an even number of characters after '0x'."
133+
)
134+
135+
# Ensure it's a valid hexadecimal string
136+
if not re.fullmatch(r"[a-fA-F0-9]*", hex_part): # `*` allows empty hex_part for '0x'
137+
raise ValueError("The value must be a valid hexadecimal string.")
138+
139+
return self
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
Verify the sanity of fixture .json format
3+
"""
4+
5+
from pydantic import ValidationError
6+
7+
from .schemas.blockchain.test import BlockchainTestFixtureModel
8+
9+
10+
class VerifyFixtureJson:
11+
"""
12+
Class to verify the correctness of a fixture JSON.
13+
"""
14+
15+
def __init__(self, name: str, fixture_json: dict):
16+
self.fixture_json = fixture_json
17+
self.fixture_name = name
18+
if self.fixture_json.get("network") and not self.fixture_json.get("engineNewPayloads"):
19+
self.verify_blockchain_fixture_json()
20+
21+
def verify_blockchain_fixture_json(self):
22+
"""
23+
Function to verify blockchain json fixture
24+
"""
25+
try:
26+
BlockchainTestFixtureModel(**self.fixture_json)
27+
except ValidationError as e:
28+
raise Exception(
29+
f"Error in generated blockchain test json ({self.fixture_name})" + e.json()
30+
)

‎whitelist.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ fp2
177177
fromhex
178178
frozenbidict
179179
func
180+
fullmatch
180181
g1
181182
g1add
182183
g1msm
@@ -207,7 +208,10 @@ Golang
207208
gwei
208209
hacky
209210
hardfork
211+
hash8
210212
hash32
213+
hash20
214+
hash256
211215
Hashable
212216
hasher
213217
HeaderNonce
@@ -810,4 +814,4 @@ E9
810814
EB
811815
EF
812816
F6
813-
FC
817+
FC

0 commit comments

Comments
 (0)
Please sign in to comment.