Skip to content

Commit 4e754c4

Browse files
feat(tests): enhance eip7883 test coverage (#1929)
* refactor(eip7883): update vector input structure * feat: add eip7702, gas usage and extra edge cases * feat: add fork transition test * test: add extra invalid cases * refactor(tests): Improve fixture and test descriptions for clarity * refactor(tests): add helper for invalid case * feat: add invalud boundary test cases * chore: update boundary input case * refactor(tests): simplify boundary modexp test cases * fix(tests): update fork transition test * feat(test): add gas formula egde cases * test: add extra casefor modexp invalid input * tests: port legacy modexp test * refactor: update vector and data types * refactor: remove valid case from invalid scenario * refactor: update result comparison method and test case * refactor: update comment * refactor: update naming and new cases * refactor: update valid fork configuration
1 parent f9bfe4d commit 4e754c4

File tree

8 files changed

+854
-103
lines changed

8 files changed

+854
-103
lines changed

tests/osaka/eip7883_modexp_gas_increase/conftest.py

Lines changed: 161 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -5,116 +5,209 @@
55
import pytest
66

77
from ethereum_test_forks import Fork, Osaka
8-
from ethereum_test_tools import Account, Address, Alloc, Storage, Transaction
8+
from ethereum_test_tools import Account, Address, Alloc, Bytes, Storage, Transaction, keccak256
99
from ethereum_test_tools.vm.opcode import Opcodes as Op
1010

11-
from .helpers import Vector
11+
from ...byzantium.eip198_modexp_precompile.helpers import ModExpInput
1212
from .spec import Spec, Spec7883
1313

1414

15+
@pytest.fixture
16+
def gas_old() -> int | None:
17+
"""Get old gas cost from the test vector if any."""
18+
return None
19+
20+
21+
@pytest.fixture
22+
def gas_new() -> int | None:
23+
"""Get new gas cost from the test vector if any."""
24+
return None
25+
26+
1527
@pytest.fixture
1628
def call_opcode() -> Op:
17-
"""Return default call used to call the precompile."""
29+
"""Return call operation used to call the precompile."""
1830
return Op.CALL
1931

2032

2133
@pytest.fixture
22-
def gas_measure_contract(pre: Alloc, call_opcode: Op, fork: Fork, vector: Vector) -> Address:
23-
"""Deploys a contract that measures ModExp gas consumption."""
34+
def call_contract_post_storage() -> Storage:
35+
"""
36+
Storage of the test contract after the transaction is executed.
37+
Note: Fixture `call_contract_code` fills the actual expected storage values.
38+
"""
39+
return Storage()
40+
41+
42+
@pytest.fixture
43+
def call_succeeds() -> bool:
44+
"""
45+
By default, depending on the expected output, we can deduce if the call is expected to succeed
46+
or fail.
47+
"""
48+
return True
49+
50+
51+
@pytest.fixture
52+
def gas_measure_contract(
53+
pre: Alloc,
54+
call_opcode: Op,
55+
fork: Fork,
56+
modexp_expected: bytes,
57+
precompile_gas: int,
58+
precompile_gas_modifier: int,
59+
call_contract_post_storage: Storage,
60+
call_succeeds: bool,
61+
) -> Address:
62+
"""
63+
Deploys a contract that measures ModExp gas consumption and execution result.
64+
65+
Always stored:
66+
storage[0]: precompile call success
67+
storage[1]: return data length from precompile
68+
Only if the precompile call succeeds:
69+
storage[2]: gas consumed by precompile
70+
storage[3]: hash of return data from precompile
71+
"""
72+
assert call_opcode in [Op.CALL, Op.CALLCODE, Op.DELEGATECALL, Op.STATICCALL]
73+
value = [0] if call_opcode in [Op.CALL, Op.CALLCODE] else []
74+
2475
call_code = call_opcode(
25-
address=Spec.MODEXP_ADDRESS,
26-
value=0,
27-
args_offset=0,
28-
args_size=Op.CALLDATASIZE,
76+
precompile_gas + precompile_gas_modifier,
77+
Spec.MODEXP_ADDRESS,
78+
*value,
79+
0,
80+
Op.CALLDATASIZE(),
81+
0,
82+
0,
2983
)
84+
3085
gas_costs = fork.gas_costs()
3186
extra_gas = (
3287
gas_costs.G_WARM_ACCOUNT_ACCESS
33-
+ (gas_costs.G_VERY_LOW * (len(call_opcode.kwargs) - 2)) # type: ignore
34-
+ (gas_costs.G_BASE * 3)
88+
+ (gas_costs.G_VERY_LOW * (len(call_opcode.kwargs) - 1)) # type: ignore
89+
+ gas_costs.G_BASE # CALLDATASIZE
90+
+ gas_costs.G_BASE # GAS
3591
)
36-
measure_code = (
92+
93+
# Build the gas measurement contract code
94+
# Stack operations:
95+
# [gas_start]
96+
# [gas_start, call_result]
97+
# [gas_start, call_result, gas_end]
98+
# [gas_start, gas_end, call_result]
99+
call_result_measurement = Op.GAS + call_code + Op.GAS + Op.SWAP1
100+
101+
# Calculate gas consumed: gas_start - (gas_end + extra_gas)
102+
# Stack Operation:
103+
# [gas_start, gas_end]
104+
# [gas_start, gas_end, extra_gas]
105+
# [gas_start, gas_end + extra_gas]
106+
# [gas_end + extra_gas, gas_start]
107+
# [gas_consumed]
108+
gas_calculation = Op.PUSH2[extra_gas] + Op.ADD + Op.SWAP1 + Op.SUB
109+
110+
code = (
37111
Op.CALLDATACOPY(dest_offset=0, offset=0, size=Op.CALLDATASIZE)
38-
+ Op.GAS # [gas_start]
39-
+ call_code # [gas_start, call_result]
40-
+ Op.GAS # [gas_start, call_result, gas_end]
41-
+ Op.SWAP1 # [gas_start, gas_end, call_result]
42-
+ Op.PUSH1[0] # [gas_start, gas_end, call_result, 0]
43-
+ Op.SSTORE # [gas_start, gas_end]
44-
+ Op.PUSH2[extra_gas] # [gas_start, gas_end, extra_gas]
45-
+ Op.ADD # [gas_start, gas_end + extra_gas]
46-
+ Op.SWAP1 # [gas_end + extra_gas, gas_start]
47-
+ Op.SUB # [gas_start - (gas_end + extra_gas)]
48-
+ Op.PUSH1[1] # [gas_start - (gas_end + extra_gas), 1]
49-
+ Op.SSTORE # []
112+
+ Op.SSTORE(call_contract_post_storage.store_next(call_succeeds), call_result_measurement)
113+
+ Op.SSTORE(
114+
call_contract_post_storage.store_next(len(modexp_expected)),
115+
Op.RETURNDATASIZE(),
116+
)
50117
)
51-
measure_code += Op.SSTORE(2, Op.RETURNDATASIZE())
52-
for i in range(len(vector.expected) // 32):
53-
measure_code += Op.RETURNDATACOPY(0, i * 32, 32)
54-
measure_code += Op.SSTORE(i + 3, Op.MLOAD(0))
55-
measure_code += Op.STOP()
56-
return pre.deploy_contract(measure_code)
118+
119+
if call_succeeds:
120+
code += Op.SSTORE(call_contract_post_storage.store_next(precompile_gas), gas_calculation)
121+
code += Op.RETURNDATACOPY(dest_offset=0, offset=0, size=Op.RETURNDATASIZE())
122+
code += Op.SSTORE(
123+
call_contract_post_storage.store_next(keccak256(Bytes(modexp_expected))),
124+
Op.SHA3(0, Op.RETURNDATASIZE()),
125+
)
126+
return pre.deploy_contract(code)
57127

58128

59129
@pytest.fixture
60-
def precompile_gas(fork: Fork, vector: Vector) -> int:
130+
def precompile_gas(
131+
fork: Fork, modexp_input: ModExpInput, gas_old: int | None, gas_new: int | None
132+
) -> int:
61133
"""Calculate gas cost for the ModExp precompile and verify it matches expected gas."""
62134
spec = Spec if fork < Osaka else Spec7883
63-
expected_gas = vector.gas_old if fork < Osaka else vector.gas_new
64-
calculated_gas = spec.calculate_gas_cost(
65-
len(vector.input.base),
66-
len(vector.input.modulus),
67-
len(vector.input.exponent),
68-
vector.input.exponent,
69-
)
70-
assert calculated_gas == expected_gas, (
71-
f"Calculated gas {calculated_gas} != Vector gas {expected_gas}\n"
72-
f"Lengths: base: {hex(len(vector.input.base))} ({len(vector.input.base)}), "
73-
f"exponent: {hex(len(vector.input.exponent))} ({len(vector.input.exponent)}), "
74-
f"modulus: {hex(len(vector.input.modulus))} ({len(vector.input.modulus)})\n"
75-
f"Exponent: {vector.input.exponent} "
76-
f"({int.from_bytes(vector.input.exponent, byteorder='big')})"
77-
)
78-
return calculated_gas
135+
try:
136+
calculated_gas = spec.calculate_gas_cost(
137+
len(modexp_input.base),
138+
len(modexp_input.modulus),
139+
len(modexp_input.exponent),
140+
modexp_input.exponent,
141+
)
142+
if gas_old is not None and gas_new is not None:
143+
expected_gas = gas_old if fork < Osaka else gas_new
144+
assert calculated_gas == expected_gas, (
145+
f"Calculated gas {calculated_gas} != Vector gas {expected_gas}\n"
146+
f"Lengths: base: {hex(len(modexp_input.base))} ({len(modexp_input.base)}), "
147+
f"exponent: {hex(len(modexp_input.exponent))} ({len(modexp_input.exponent)}), "
148+
f"modulus: {hex(len(modexp_input.modulus))} ({len(modexp_input.modulus)})\n"
149+
f"Exponent: {modexp_input.exponent} "
150+
f"({int.from_bytes(modexp_input.exponent, byteorder='big')})"
151+
)
152+
return calculated_gas
153+
except Exception as e:
154+
print(f"Warning: Error calculating gas, using minimum: {e}")
155+
return 500 if fork >= Osaka else 200
156+
157+
158+
@pytest.fixture
159+
def precompile_gas_modifier() -> int:
160+
"""Return the gas modifier for the ModExp precompile."""
161+
return 0
79162

80163

81164
@pytest.fixture
82165
def tx(
83-
fork: Fork,
84166
pre: Alloc,
85167
gas_measure_contract: Address,
86-
vector: Vector,
87-
precompile_gas: int,
168+
modexp_input: ModExpInput,
169+
tx_gas_limit: int,
88170
) -> Transaction:
89171
"""Transaction to measure gas consumption of the ModExp precompile."""
90-
intrinsic_gas_cost_calc = fork.transaction_intrinsic_cost_calculator()
91-
intrinsic_gas_cost = intrinsic_gas_cost_calc(calldata=vector.input)
92-
memory_expansion_gas_calc = fork.memory_expansion_gas_calculator()
93-
memory_expansion_gas = memory_expansion_gas_calc(new_bytes=len(bytes(vector.input)))
94-
sstore_gas = fork.gas_costs().G_STORAGE_SET * (len(vector.expected) // 32)
95172
return Transaction(
96173
sender=pre.fund_eoa(),
97174
to=gas_measure_contract,
98-
data=vector.input,
99-
gas_limit=intrinsic_gas_cost
175+
data=bytes(modexp_input),
176+
gas_limit=tx_gas_limit,
177+
)
178+
179+
180+
@pytest.fixture
181+
def tx_gas_limit(
182+
fork: Fork, modexp_expected: bytes, modexp_input: ModExpInput, precompile_gas: int
183+
) -> int:
184+
"""Transaction gas limit used for the test (Can be overridden in the test)."""
185+
intrinsic_gas_cost_calculator = fork.transaction_intrinsic_cost_calculator()
186+
memory_expansion_gas_calculator = fork.memory_expansion_gas_calculator()
187+
sstore_gas = fork.gas_costs().G_STORAGE_SET * (len(modexp_expected) // 32)
188+
extra_gas = 100_000
189+
190+
total_gas = (
191+
extra_gas
192+
+ intrinsic_gas_cost_calculator(calldata=bytes(modexp_input))
193+
+ memory_expansion_gas_calculator(new_bytes=len(bytes(modexp_input)))
100194
+ precompile_gas
101-
+ memory_expansion_gas
102195
+ sstore_gas
103-
+ 100_000,
104196
)
105197

198+
tx_gas_limit_cap = fork.transaction_gas_limit_cap()
199+
200+
if tx_gas_limit_cap is not None:
201+
return min(tx_gas_limit_cap, total_gas)
202+
return total_gas
203+
106204

107205
@pytest.fixture
108206
def post(
109207
gas_measure_contract: Address,
110-
precompile_gas: int,
111-
vector: Vector,
208+
call_contract_post_storage: Storage,
112209
) -> Dict[Address, Account]:
113210
"""Return expected post state with gas consumption check."""
114-
storage = Storage()
115-
storage[0] = 1
116-
storage[1] = precompile_gas
117-
storage[2] = len(vector.expected)
118-
for i in range(len(vector.expected) // 32):
119-
storage[i + 3] = vector.expected[i * 32 : (i + 1) * 32]
120-
return {gas_measure_contract: Account(storage=storage)}
211+
return {
212+
gas_measure_contract: Account(storage=call_contract_post_storage),
213+
}
Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,58 @@
11
"""Helper functions for the EIP-7883 ModExp gas cost increase tests."""
22

3-
import json
43
import os
54
from typing import Annotated, List
65

7-
from pydantic import BaseModel, Field, PlainValidator
6+
import pytest
7+
from pydantic import BaseModel, ConfigDict, Field, PlainValidator, RootModel, TypeAdapter
8+
from pydantic.alias_generators import to_pascal
89

910
from ethereum_test_tools import Bytes
1011

1112
from ...byzantium.eip198_modexp_precompile.helpers import ModExpInput
1213

1314

15+
def current_python_script_directory(*args: str) -> str:
16+
"""Get the current Python script directory."""
17+
return os.path.join(os.path.dirname(os.path.realpath(__file__)), *args)
18+
19+
1420
class Vector(BaseModel):
1521
"""A vector for the ModExp gas cost increase tests."""
1622

17-
input: Annotated[ModExpInput, PlainValidator(ModExpInput.from_bytes)] = Field(
23+
modexp_input: Annotated[ModExpInput, PlainValidator(ModExpInput.from_bytes)] = Field(
1824
..., alias="Input"
1925
)
20-
expected: Bytes = Field(..., alias="Expected")
26+
modexp_expected: Bytes = Field(..., alias="Expected")
2127
name: str = Field(..., alias="Name")
22-
gas_old: int | None = Field(..., alias="GasOld")
23-
gas_new: int | None = Field(..., alias="GasNew")
28+
gas_old: int | None = Field(default=None, alias="GasOld")
29+
gas_new: int | None = Field(default=None, alias="GasNew")
2430

25-
@staticmethod
26-
def from_json(vector_json: dict) -> "Vector":
27-
"""Create a Vector from a JSON dictionary."""
28-
return Vector.model_validate(vector_json)
31+
model_config = ConfigDict(alias_generator=to_pascal)
2932

30-
@staticmethod
31-
def from_file(filename: str) -> List["Vector"]:
32-
"""Create a list of Vectors from a file."""
33-
with open(current_python_script_directory(filename), "r") as f:
34-
vectors_json = json.load(f)
35-
return [Vector.from_json(vector_json) for vector_json in vectors_json]
33+
def to_pytest_param(self):
34+
"""Convert the test vector to a tuple that can be used as a parameter in a pytest test."""
35+
return pytest.param(
36+
self.modexp_input, self.modexp_expected, self.gas_old, self.gas_new, id=self.name
37+
)
3638

3739

38-
def current_python_script_directory(*args: str) -> str:
39-
"""Get the current Python script directory."""
40-
return os.path.join(os.path.dirname(os.path.realpath(__file__)), *args)
40+
class VectorList(RootModel):
41+
"""A list of test vectors for the ModExp gas cost increase tests."""
42+
43+
root: List[Vector]
44+
45+
46+
VectorListAdapter = TypeAdapter(VectorList)
47+
48+
49+
def vectors_from_file(filename: str) -> List:
50+
"""Load test vectors from a file."""
51+
with open(
52+
current_python_script_directory(
53+
"vector",
54+
filename,
55+
),
56+
"rb",
57+
) as f:
58+
return [v.to_pytest_param() for v in VectorListAdapter.validate_json(f.read()).root]

tests/osaka/eip7883_modexp_gas_increase/spec.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from dataclasses import dataclass
44

5+
from ...byzantium.eip198_modexp_precompile.helpers import ModExpInput
6+
57

68
@dataclass(frozen=True)
79
class ReferenceSpec:
@@ -32,11 +34,23 @@ class Spec:
3234
LARGE_BASE_MODULUS_MULTIPLIER = 1
3335
MAX_LENGTH_THRESHOLD = 32
3436
EXPONENT_BYTE_MULTIPLIER = 8
37+
MAX_LENGTH_BYTES = 1024
3538

3639
WORD_SIZE = 8
3740
EXPONENT_THRESHOLD = 32
3841
GAS_DIVISOR = 3
3942

43+
# Arbitrary Test Constants
44+
modexp_input = ModExpInput(
45+
base="e8e77626586f73b955364c7b4bbf0bb7f7685ebd40e852b164633a4acbd3244c0001020304050607",
46+
exponent="01ffffff",
47+
modulus="f01681d2220bfea4bb888a5543db8c0916274ddb1ea93b144c042c01d8164c950001020304050607",
48+
)
49+
modexp_expected = bytes.fromhex(
50+
"1abce71dc2205cce4eb6934397a88136f94641342e283cbcd30e929e85605c6718ed67f475192ffd"
51+
)
52+
modexp_error = bytes()
53+
4054
@classmethod
4155
def calculate_multiplication_complexity(cls, base_length: int, modulus_length: int) -> int:
4256
"""Calculate the multiplication complexity of the ModExp precompile."""

0 commit comments

Comments
 (0)