From 8000c1dc6538a496ab95b6db12dc14cfeab476c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Bylica?= Date: Sun, 26 Jul 2026 12:45:24 +0200 Subject: [PATCH] feat(tests): cover ModExp gas before EIP-2565 The gas measurement harness lived in the EIP-7883 directory and every test using it started at Berlin, so the EIP-198 schedule that prices the precompile from Byzantium until Berlin had no coverage: the only ModExp tests reaching those forks call with all the gas available. Move the harness down to the EIP-198 directory, which already owns `ModExpInput` and is imported by the later repricing EIPs, and select the gas formula through a `modexp_spec` fixture that a repricing directory overrides. The formulas move with it: `Spec198` is new, `Spec2565` is the former `Spec` body, and EIP-7883 keeps overriding it. Two fixes were needed to reach Byzantium. The harness subtracted `gas_costs().WARM_ACCESS` as the cost of the call, but that field holds the EIP-2929 value on every fork, so before Berlin it under-counted the flat EIP-150 charge by 600 gas. And `get_exponent_head()` left-padded an exponent whose input is truncated, while reading past the end of the input yields zeros on the right; the EIP-2565 minimum of 200 gas had hidden the difference, which is observable under EIP-198 where a call can be free. With that in place, the legacy vectors and the exact-gas boundary test now run from Byzantium, and a new test pins each branch of the EIP-198 multiplication complexity formula at its exact cost and one gas less. Claude-Session: https://claude.ai/code/session_019YyttTB1mqHzWa77K3sEoT --- .../eip198_modexp_precompile/conftest.py | 311 ++++++++++++++++++ .../eip198_modexp_precompile/helpers.py | 14 +- .../eip198_modexp_precompile/spec.py | 130 ++++++++ .../test_modexp_gas.py | 95 ++++++ .../eip7883_modexp_gas_increase/conftest.py | 302 ++--------------- .../osaka/eip7883_modexp_gas_increase/spec.py | 89 +---- .../test_modexp_thresholds.py | 4 +- 7 files changed, 585 insertions(+), 360 deletions(-) create mode 100644 tests/byzantium/eip198_modexp_precompile/conftest.py create mode 100644 tests/byzantium/eip198_modexp_precompile/spec.py create mode 100644 tests/byzantium/eip198_modexp_precompile/test_modexp_gas.py diff --git a/tests/byzantium/eip198_modexp_precompile/conftest.py b/tests/byzantium/eip198_modexp_precompile/conftest.py new file mode 100644 index 00000000000..b04e4558d82 --- /dev/null +++ b/tests/byzantium/eip198_modexp_precompile/conftest.py @@ -0,0 +1,311 @@ +""" +Shared pytest definitions for the ModExp precompile tests. + +The gas measurement fixtures live here, with the Byzantium pricing, because +every later repricing of the precompile reuses them: a directory testing a +repricing EIP overrides the `modexp_spec` fixture with its own gas formula. +""" + +from typing import Dict, Type + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + Bytes, + Environment, + Fork, + Op, + Storage, + Transaction, + keccak256, +) +from execution_testing.forks import Berlin + +from .helpers import ModExpInput +from .spec import ModExpGasSpec, modexp_gas_spec + +CALL_GAS_BEFORE_BERLIN = 700 +"""Gas charged by EIP-150 for a call to an existing account.""" + + +@pytest.fixture +def modexp_spec(fork: Fork) -> Type[ModExpGasSpec]: + """Return the ModExp gas specification in effect at the given fork.""" + return modexp_gas_spec(fork) + + +@pytest.fixture +def gas_old() -> int | None: + """Get old gas cost from the test vector if any.""" + return None + + +@pytest.fixture +def gas_new() -> int | None: + """Get new gas cost from the test vector if any.""" + return None + + +@pytest.fixture +def call_opcode() -> Op: + """Return call operation used to call the precompile.""" + return Op.CALL + + +@pytest.fixture +def call_contract_post_storage() -> Storage: + """ + Storage of the test contract after the transaction is executed. Note: + Fixture `call_contract_code` fills the actual expected storage values. + """ + return Storage() + + +@pytest.fixture +def total_tx_gas_needed( + fork: Fork, + modexp_input: ModExpInput, + precompile_gas: int, +) -> int: + """Calculate total tx gas needed for the transaction.""" + intrinsic_gas_cost_calculator = ( + fork.transaction_intrinsic_cost_calculator() + ) + memory_expansion_gas_calculator = fork.memory_expansion_gas_calculator() + sstore_gas = Op.SSTORE(key_warm=False).gas_cost(fork) * 4 + precompile_gas_with_margin = precompile_gas * 64 // 63 + extra_gas = 100_000 + if fork.is_eip_enabled(8037): + extra_gas = 500_000 + + return ( + extra_gas + + intrinsic_gas_cost_calculator(calldata=bytes(modexp_input)) + + memory_expansion_gas_calculator(new_bytes=len(bytes(modexp_input))) + + precompile_gas_with_margin + + sstore_gas + ) + + +@pytest.fixture +def exceeds_tx_gas_cap( + total_tx_gas_needed: int, + fork: Fork, + env: Environment, + precompile_gas: int, +) -> bool: + """Determine if total gas requirements exceed transaction gas cap.""" + if fork.is_eip_enabled(8037): + # EIP-8037: tx.gas can exceed TX_MAX_GAS_LIMIT; excess fills + # state_gas_reservoir. But regular gas is still capped at + # TX_MAX_GAS_LIMIT, so if the precompile alone needs more regular gas + # than the budget, the call will fail. + cap = fork.transaction_gas_limit_cap() + return cap is not None and precompile_gas > cap + tx_gas_limit_cap = fork.transaction_gas_limit_cap() or env.gas_limit + return total_tx_gas_needed > tx_gas_limit_cap + + +@pytest.fixture +def expected_tx_cap_fail() -> bool: + """Whether this test is expected to fail due to transaction gas cap.""" + return False + + +@pytest.fixture +def call_succeeds( + exceeds_tx_gas_cap: bool, expected_tx_cap_fail: bool +) -> bool: + """ + Determine whether the ModExp precompile call should succeed or fail. By + default, depending on the expected output, we assume it succeeds. Under + EIP-7825, transactions requiring more gas than the cap should fail only if + unexpected. + """ + if exceeds_tx_gas_cap and not expected_tx_cap_fail: + pytest.fail( + "Test unexpectedly exceeds tx gas cap. " + "Either mark with `expected_tx_cap_fail=True` or adjust inputs." + ) + return not exceeds_tx_gas_cap + + +@pytest.fixture +def gas_measure_contract( + pre: Alloc, + call_opcode: Op, + fork: Fork, + modexp_spec: Type[ModExpGasSpec], + modexp_expected: bytes, + precompile_gas: int, + precompile_gas_modifier: int, + call_contract_post_storage: Storage, + call_succeeds: bool, +) -> Address: + """ + Deploys a contract that measures ModExp gas consumption and execution + result. + + Always stored: + storage[0]: precompile call success + storage[1]: return data length from precompile + + Only if the precompile call succeeds: + storage[2]: gas consumed by precompile + storage[3]: hash of return data from precompile + """ + assert call_opcode in [ + Op.CALL, + Op.CALLCODE, + Op.DELEGATECALL, + Op.STATICCALL, + ] + value = [0] if call_opcode in [Op.CALL, Op.CALLCODE] else [] + + gas_used = ( + precompile_gas + precompile_gas_modifier + if precompile_gas_modifier != float("inf") + else Environment().gas_limit + ) + + call_code = call_opcode( + gas_used, + modexp_spec.MODEXP_ADDRESS, + *value, + 0, + Op.CALLDATASIZE(), + 0, + 0, + ) + + gas_costs = fork.gas_costs() + # A precompile is always warm, so EIP-2929 charges the warm access cost + # for the call. Before Berlin the charge is the flat EIP-150 call cost, + # which `gas_costs()` does not carry. + call_gas = ( + gas_costs.WARM_ACCESS if fork >= Berlin else CALL_GAS_BEFORE_BERLIN + ) + extra_gas = ( + call_gas + + (gas_costs.VERY_LOW * (len(call_opcode.kwargs) - 1)) + + gas_costs.BASE # CALLDATASIZE + + gas_costs.BASE # GAS + ) + + # Build the gas measurement contract code + # Stack operations: + # [gas_start] + # [gas_start, call_result] + # [gas_start, call_result, gas_end] + # [gas_start, gas_end, call_result] + call_result_measurement = Op.GAS + call_code + Op.GAS + Op.SWAP1 + + # Calculate gas consumed: gas_start - (gas_end + extra_gas) + # Stack Operation: + # [gas_start, gas_end] + # [gas_start, gas_end, extra_gas] + # [gas_start, gas_end + extra_gas] + # [gas_end + extra_gas, gas_start] + # [gas_consumed] + gas_calculation = Op.PUSH2[extra_gas] + Op.ADD + Op.SWAP1 + Op.SUB + + code = ( + Op.CALLDATACOPY(dest_offset=0, offset=0, size=Op.CALLDATASIZE) + + Op.SSTORE( + call_contract_post_storage.store_next(call_succeeds), + call_result_measurement, + ) + + Op.SSTORE( + call_contract_post_storage.store_next( + len(modexp_expected) if call_succeeds else 0 + ), + Op.RETURNDATASIZE(), + ) + ) + + if call_succeeds: + code += Op.SSTORE( + call_contract_post_storage.store_next(precompile_gas), + gas_calculation, + ) + code += Op.RETURNDATACOPY( + dest_offset=0, offset=0, size=Op.RETURNDATASIZE() + ) + code += Op.SSTORE( + call_contract_post_storage.store_next( + keccak256(Bytes(modexp_expected)) + ), + Op.SHA3(0, Op.RETURNDATASIZE()), + ) + return pre.deploy_contract(code) + + +@pytest.fixture +def precompile_gas( + fork: Fork, + modexp_spec: Type[ModExpGasSpec], + modexp_input: ModExpInput, + gas_old: int | None, + gas_new: int | None, +) -> int: + """ + Calculate gas cost for the ModExp precompile and verify it matches expected + gas. + """ + try: + calculated_gas = modexp_spec.calculate_gas_cost(modexp_input) + if gas_old is not None and gas_new is not None: + expected_gas = ( + gas_old if not fork.is_eip_enabled(7883) else gas_new + ) + base_len = len(modexp_input.base) + exp_len = len(modexp_input.exponent) + mod_len = len(modexp_input.modulus) + exp_int = int.from_bytes(modexp_input.exponent, byteorder="big") + error_msg = ( + f"Calculated gas {calculated_gas} != " + f"Vector gas {expected_gas}\n" + f"Lengths: base: {hex(base_len)} ({base_len}), " + f"exponent: {hex(exp_len)} ({exp_len}), " + f"modulus: {hex(mod_len)} ({mod_len})\n" + f"Exponent: {modexp_input.exponent} ({exp_int})" + ) + assert calculated_gas == expected_gas, error_msg + return calculated_gas + except Exception: + # Used for `test_modexp_invalid_inputs` we expect the call to not + # succeed. Return is for completeness. + return modexp_spec.MIN_GAS + + +@pytest.fixture +def precompile_gas_modifier() -> int: + """Return the gas modifier for the ModExp precompile.""" + return 0 + + +@pytest.fixture +def tx( + pre: Alloc, + gas_measure_contract: Address, + modexp_input: ModExpInput, +) -> Transaction: + """Transaction to measure gas consumption of the ModExp precompile.""" + return Transaction( + sender=pre.fund_eoa(), + to=gas_measure_contract, + data=bytes(modexp_input), + ) + + +@pytest.fixture +def post( + gas_measure_contract: Address, + call_contract_post_storage: Storage, +) -> Dict[Address, Account]: + """Return expected post state with gas consumption check.""" + return { + gas_measure_contract: Account(storage=call_contract_post_storage), + } diff --git a/tests/byzantium/eip198_modexp_precompile/helpers.py b/tests/byzantium/eip198_modexp_precompile/helpers.py index 5274d007787..6b7ec5172a9 100644 --- a/tests/byzantium/eip198_modexp_precompile/helpers.py +++ b/tests/byzantium/eip198_modexp_precompile/helpers.py @@ -137,14 +137,16 @@ def get_exponent_head(self) -> int: raw = self.raw_input if self.raw_input is not None else bytes(self) base_length, exponent_length, _ = self.get_declared_lengths() exp_start = 96 + base_length + head_length = min(32, exponent_length) - # Extract up to 32 bytes of exponent data - exp_head_bytes = raw[exp_start : exp_start + min(32, exponent_length)] - - # Pad with zeros if less than 32 bytes - exp_head_bytes = exp_head_bytes.rjust(32, b"\0") + # Extract the head of the exponent, reading input past its end as + # zeros: the head keeps its declared length, so the padding goes on + # the right. + exp_head_bytes = raw[exp_start : exp_start + head_length].ljust( + head_length, b"\0" + ) - return int.from_bytes(exp_head_bytes[:32], byteorder="big") + return int.from_bytes(exp_head_bytes, byteorder="big") class ModExpOutput(TestParameterGroup): diff --git a/tests/byzantium/eip198_modexp_precompile/spec.py b/tests/byzantium/eip198_modexp_precompile/spec.py new file mode 100644 index 00000000000..2c43025dc28 --- /dev/null +++ b/tests/byzantium/eip198_modexp_precompile/spec.py @@ -0,0 +1,130 @@ +"""Defines the ModExp precompile gas cost specifications up to EIP-2565.""" + +from typing import Type + +from execution_testing import Fork +from execution_testing.forks import Berlin + +from .helpers import ModExpInput + + +def ceiling_division(a: int, b: int) -> int: + """ + Calculate the ceil without using floating point. Used by many of the EVM's + formulas. + """ + return -(a // -b) + + +class ModExpGasSpec: + """ + Base for the ModExp precompile gas cost calculations. Subclasses define + the pricing introduced by a single EIP. + """ + + MODEXP_ADDRESS = 0x05 + MIN_GAS = 0 + + EXPONENT_THRESHOLD = 32 + EXPONENT_BYTE_MULTIPLIER = 8 + GAS_DIVISOR = 1 + + @classmethod + def calculate_multiplication_complexity( + cls, base_length: int, modulus_length: int + ) -> int: + """Calculate the multiplication complexity of the ModExp precompile.""" + raise NotImplementedError + + @classmethod + def calculate_iteration_count(cls, modexp_input: ModExpInput) -> int: + """ + Calculate the iteration count of the ModExp precompile. This handles + length mismatch cases by using declared lengths from the raw input and + only the first 32 bytes of exponent data for iteration calculation. + """ + _, exponent_length, _ = modexp_input.get_declared_lengths() + exponent_head = modexp_input.get_exponent_head() + head_bits = max(exponent_head.bit_length() - 1, 0) + if exponent_length <= cls.EXPONENT_THRESHOLD: + iteration_count = head_bits + else: + iteration_count = ( + cls.EXPONENT_BYTE_MULTIPLIER + * (exponent_length - cls.EXPONENT_THRESHOLD) + + head_bits + ) + return max(iteration_count, 1) + + @classmethod + def calculate_gas_cost(cls, modexp_input: ModExpInput) -> int: + """Calculate the ModExp gas cost.""" + base_length, _, modulus_length = modexp_input.get_declared_lengths() + multiplication_complexity = cls.calculate_multiplication_complexity( + base_length, modulus_length + ) + iteration_count = cls.calculate_iteration_count(modexp_input) + return max( + cls.MIN_GAS, + (multiplication_complexity * iteration_count // cls.GAS_DIVISOR), + ) + + +class Spec198(ModExpGasSpec): + """ + Constants and helpers for the ModExp gas cost calculation as introduced by + EIP-198, in effect from Byzantium until EIP-2565 takes over in Berlin. + There is no minimum charge, so a call over empty operands is free. + """ + + GAS_DIVISOR = 20 + + QUADRATIC_LENGTH_THRESHOLD = 64 + LINEAR_LENGTH_THRESHOLD = 1024 + + @classmethod + def calculate_multiplication_complexity( + cls, base_length: int, modulus_length: int + ) -> int: + """ + Calculate the multiplication complexity of the ModExp precompile for + EIP-198, which is piecewise in the length of the longer operand. + """ + max_length = max(base_length, modulus_length) + if max_length <= cls.QUADRATIC_LENGTH_THRESHOLD: + return max_length**2 + if max_length <= cls.LINEAR_LENGTH_THRESHOLD: + return max_length**2 // 4 + 96 * max_length - 3072 + return max_length**2 // 16 + 480 * max_length - 199680 + + +class Spec2565(ModExpGasSpec): + """ + Constants and helpers for the ModExp gas cost calculation as introduced by + EIP-2565, in effect from Berlin. + """ + + MIN_GAS = 200 + + LARGE_BASE_MODULUS_MULTIPLIER = 1 + MAX_LENGTH_THRESHOLD = 32 + MAX_LENGTH_BYTES = 1024 + + WORD_SIZE = 8 + GAS_DIVISOR = 3 + + @classmethod + def calculate_multiplication_complexity( + cls, base_length: int, modulus_length: int + ) -> int: + """Calculate the multiplication complexity of the ModExp precompile.""" + max_length = max(base_length, modulus_length) + words = ceiling_division(max_length, cls.WORD_SIZE) + if max_length <= cls.MAX_LENGTH_THRESHOLD: + return words**2 + return cls.LARGE_BASE_MODULUS_MULTIPLIER * words**2 + + +def modexp_gas_spec(fork: Fork) -> Type[ModExpGasSpec]: + """Return the ModExp gas specification in effect at the given fork.""" + return Spec2565 if fork >= Berlin else Spec198 diff --git a/tests/byzantium/eip198_modexp_precompile/test_modexp_gas.py b/tests/byzantium/eip198_modexp_precompile/test_modexp_gas.py new file mode 100644 index 00000000000..0450a5e0fab --- /dev/null +++ b/tests/byzantium/eip198_modexp_precompile/test_modexp_gas.py @@ -0,0 +1,95 @@ +""" +Gas cost tests for the +[EIP-198: MODEXP Precompile](https://eips.ethereum.org/EIPS/eip-198). + +The gas charged for a call is pinned by giving the precompile exactly its +cost and one gas less, over operand lengths that select each branch of the +multiplication complexity formula. EIP-2565 replaces that formula in Berlin, +so the same cases also pin its schedule. The EIP-7883 schedule that follows +in Osaka is covered by the tests of that EIP. +""" + +from typing import Dict + +import pytest +from execution_testing import Alloc, StateTestFiller, Transaction + +from .helpers import ModExpInput + +REFERENCE_SPEC_GIT_PATH = "EIPS/eip-198.md" +REFERENCE_SPEC_VERSION = "5c8f066acb210c704ef80c1033a941aa5374aac5" + + +@pytest.mark.parametrize( + "modexp_input,modexp_expected", + [ + pytest.param( + ModExpInput(base="ff" * 64, exponent="ff" * 32, modulus="07"), + bytes.fromhex("06"), + id="quadratic_branch_edge", + ), + pytest.param( + ModExpInput(base="ff" * 65, exponent="ff" * 32, modulus="07"), + bytes.fromhex("01"), + id="linear_branch_edge", + ), + pytest.param( + ModExpInput(base="ff" * 1024, exponent="03", modulus="07"), + bytes.fromhex("06"), + id="linear_branch_max", + ), + pytest.param( + ModExpInput(base="ff" * 1025, exponent="03", modulus="07"), + bytes.fromhex("01"), + id="beyond_linear_branch", + ), + ], +) +@pytest.mark.parametrize( + "precompile_gas_modifier,call_succeeds", + [ + pytest.param(0, True, id="exact_gas"), + pytest.param(-1, False, id="insufficient_gas"), + ], +) +@pytest.mark.valid_from("Byzantium") +@pytest.mark.valid_until("Prague") +def test_modexp_gas_boundary( + state_test: StateTestFiller, + pre: Alloc, + tx: Transaction, + post: Dict, +) -> None: + """ + Call ModExp with exactly its gas cost and with one gas less, over the + operand lengths that select each branch of the multiplication complexity + formula: the quadratic branch and its upper edge at 64 bytes, the linear + branch from 65 bytes to its upper edge at 1024 bytes, and the branch + beyond that. + """ + state_test(pre=pre, tx=tx, post=post) + + +@pytest.mark.parametrize( + "modexp_input,modexp_expected", + [ + pytest.param( + ModExpInput(base="", exponent="", modulus=""), + bytes(), + id="empty_operands", + ), + ], +) +@pytest.mark.valid_from("Byzantium") +@pytest.mark.valid_until("Prague") +def test_modexp_minimum_gas( + state_test: StateTestFiller, + pre: Alloc, + tx: Transaction, + post: Dict, +) -> None: + """ + Call ModExp over empty operands with exactly its gas cost, which is zero + until EIP-2565 introduces a minimum charge of 200 gas. + """ + state_test(pre=pre, tx=tx, post=post) diff --git a/tests/osaka/eip7883_modexp_gas_increase/conftest.py b/tests/osaka/eip7883_modexp_gas_increase/conftest.py index 4f824901b99..d02de2da947 100644 --- a/tests/osaka/eip7883_modexp_gas_increase/conftest.py +++ b/tests/osaka/eip7883_modexp_gas_increase/conftest.py @@ -1,288 +1,30 @@ """Shared pytest definitions for EIP-7883 tests.""" -from typing import Dict +from typing import Type import pytest -from execution_testing import ( - Account, - Address, - Alloc, - Bytes, - Environment, - Fork, - Op, - Storage, - Transaction, - keccak256, +from execution_testing import Fork + +from ...byzantium.eip198_modexp_precompile.conftest import ( # noqa: F401 + call_contract_post_storage, + call_opcode, + call_succeeds, + exceeds_tx_gas_cap, + expected_tx_cap_fail, + gas_measure_contract, + gas_new, + gas_old, + post, + precompile_gas, + precompile_gas_modifier, + total_tx_gas_needed, + tx, ) - -from ...byzantium.eip198_modexp_precompile.helpers import ModExpInput -from .spec import Spec, Spec7883 - - -@pytest.fixture -def gas_old() -> int | None: - """Get old gas cost from the test vector if any.""" - return None - - -@pytest.fixture -def gas_new() -> int | None: - """Get new gas cost from the test vector if any.""" - return None - - -@pytest.fixture -def call_opcode() -> Op: - """Return call operation used to call the precompile.""" - return Op.CALL - - -@pytest.fixture -def call_contract_post_storage() -> Storage: - """ - Storage of the test contract after the transaction is executed. Note: - Fixture `call_contract_code` fills the actual expected storage values. - """ - return Storage() - - -@pytest.fixture -def total_tx_gas_needed( - fork: Fork, - modexp_input: ModExpInput, - precompile_gas: int, -) -> int: - """Calculate total tx gas needed for the transaction.""" - intrinsic_gas_cost_calculator = ( - fork.transaction_intrinsic_cost_calculator() - ) - memory_expansion_gas_calculator = fork.memory_expansion_gas_calculator() - sstore_gas = Op.SSTORE(key_warm=False).gas_cost(fork) * 4 - precompile_gas_with_margin = precompile_gas * 64 // 63 - extra_gas = 100_000 - if fork.is_eip_enabled(8037): - extra_gas = 500_000 - - return ( - extra_gas - + intrinsic_gas_cost_calculator(calldata=bytes(modexp_input)) - + memory_expansion_gas_calculator(new_bytes=len(bytes(modexp_input))) - + precompile_gas_with_margin - + sstore_gas - ) - - -@pytest.fixture -def exceeds_tx_gas_cap( - total_tx_gas_needed: int, - fork: Fork, - env: Environment, - precompile_gas: int, -) -> bool: - """Determine if total gas requirements exceed transaction gas cap.""" - if fork.is_eip_enabled(8037): - # EIP-8037: tx.gas can exceed TX_MAX_GAS_LIMIT; excess fills - # state_gas_reservoir. But regular gas is still capped at - # TX_MAX_GAS_LIMIT, so if the precompile alone needs more regular gas - # than the budget, the call will fail. - cap = fork.transaction_gas_limit_cap() - return cap is not None and precompile_gas > cap - tx_gas_limit_cap = fork.transaction_gas_limit_cap() or env.gas_limit - return total_tx_gas_needed > tx_gas_limit_cap - - -@pytest.fixture -def expected_tx_cap_fail() -> bool: - """Whether this test is expected to fail due to transaction gas cap.""" - return False - - -@pytest.fixture -def call_succeeds( - exceeds_tx_gas_cap: bool, expected_tx_cap_fail: bool -) -> bool: - """ - Determine whether the ModExp precompile call should succeed or fail. By - default, depending on the expected output, we assume it succeeds. Under - EIP-7825, transactions requiring more gas than the cap should fail only if - unexpected. - """ - if exceeds_tx_gas_cap and not expected_tx_cap_fail: - pytest.fail( - "Test unexpectedly exceeds tx gas cap. " - "Either mark with `expected_tx_cap_fail=True` or adjust inputs." - ) - return not exceeds_tx_gas_cap - - -@pytest.fixture -def gas_measure_contract( - pre: Alloc, - call_opcode: Op, - fork: Fork, - modexp_expected: bytes, - precompile_gas: int, - precompile_gas_modifier: int, - call_contract_post_storage: Storage, - call_succeeds: bool, -) -> Address: - """ - Deploys a contract that measures ModExp gas consumption and execution - result. - - Always stored: - storage[0]: precompile call success - storage[1]: return data length from precompile - - Only if the precompile call succeeds: - storage[2]: gas consumed by precompile - storage[3]: hash of return data from precompile - """ - assert call_opcode in [ - Op.CALL, - Op.CALLCODE, - Op.DELEGATECALL, - Op.STATICCALL, - ] - value = [0] if call_opcode in [Op.CALL, Op.CALLCODE] else [] - - gas_used = ( - precompile_gas + precompile_gas_modifier - if precompile_gas_modifier != float("inf") - else Environment().gas_limit - ) - - call_code = call_opcode( - gas_used, - Spec.MODEXP_ADDRESS, - *value, - 0, - Op.CALLDATASIZE(), - 0, - 0, - ) - - gas_costs = fork.gas_costs() - extra_gas = ( - gas_costs.WARM_ACCESS - + (gas_costs.VERY_LOW * (len(call_opcode.kwargs) - 1)) - + gas_costs.BASE # CALLDATASIZE - + gas_costs.BASE # GAS - ) - - # Build the gas measurement contract code - # Stack operations: - # [gas_start] - # [gas_start, call_result] - # [gas_start, call_result, gas_end] - # [gas_start, gas_end, call_result] - call_result_measurement = Op.GAS + call_code + Op.GAS + Op.SWAP1 - - # Calculate gas consumed: gas_start - (gas_end + extra_gas) - # Stack Operation: - # [gas_start, gas_end] - # [gas_start, gas_end, extra_gas] - # [gas_start, gas_end + extra_gas] - # [gas_end + extra_gas, gas_start] - # [gas_consumed] - gas_calculation = Op.PUSH2[extra_gas] + Op.ADD + Op.SWAP1 + Op.SUB - - code = ( - Op.CALLDATACOPY(dest_offset=0, offset=0, size=Op.CALLDATASIZE) - + Op.SSTORE( - call_contract_post_storage.store_next(call_succeeds), - call_result_measurement, - ) - + Op.SSTORE( - call_contract_post_storage.store_next( - len(modexp_expected) if call_succeeds else 0 - ), - Op.RETURNDATASIZE(), - ) - ) - - if call_succeeds: - code += Op.SSTORE( - call_contract_post_storage.store_next(precompile_gas), - gas_calculation, - ) - code += Op.RETURNDATACOPY( - dest_offset=0, offset=0, size=Op.RETURNDATASIZE() - ) - code += Op.SSTORE( - call_contract_post_storage.store_next( - keccak256(Bytes(modexp_expected)) - ), - Op.SHA3(0, Op.RETURNDATASIZE()), - ) - return pre.deploy_contract(code) - - -@pytest.fixture -def precompile_gas( - fork: Fork, - modexp_input: ModExpInput, - gas_old: int | None, - gas_new: int | None, -) -> int: - """ - Calculate gas cost for the ModExp precompile and verify it matches expected - gas. - """ - spec = Spec if not fork.is_eip_enabled(7883) else Spec7883 - try: - calculated_gas = spec.calculate_gas_cost(modexp_input) - if gas_old is not None and gas_new is not None: - expected_gas = ( - gas_old if not fork.is_eip_enabled(7883) else gas_new - ) - base_len = len(modexp_input.base) - exp_len = len(modexp_input.exponent) - mod_len = len(modexp_input.modulus) - exp_int = int.from_bytes(modexp_input.exponent, byteorder="big") - error_msg = ( - f"Calculated gas {calculated_gas} != " - f"Vector gas {expected_gas}\n" - f"Lengths: base: {hex(base_len)} ({base_len}), " - f"exponent: {hex(exp_len)} ({exp_len}), " - f"modulus: {hex(mod_len)} ({mod_len})\n" - f"Exponent: {modexp_input.exponent} ({exp_int})" - ) - assert calculated_gas == expected_gas, error_msg - return calculated_gas - except Exception: - # Used for `test_modexp_invalid_inputs` we expect the call to not - # succeed. Return is for completeness. - return 500 if fork.is_eip_enabled(7883) else 200 - - -@pytest.fixture -def precompile_gas_modifier() -> int: - """Return the gas modifier for the ModExp precompile.""" - return 0 - - -@pytest.fixture -def tx( - pre: Alloc, - gas_measure_contract: Address, - modexp_input: ModExpInput, -) -> Transaction: - """Transaction to measure gas consumption of the ModExp precompile.""" - return Transaction( - sender=pre.fund_eoa(), - to=gas_measure_contract, - data=bytes(modexp_input), - ) +from ...byzantium.eip198_modexp_precompile.spec import ModExpGasSpec +from .spec import modexp_spec_at @pytest.fixture -def post( - gas_measure_contract: Address, - call_contract_post_storage: Storage, -) -> Dict[Address, Account]: - """Return expected post state with gas consumption check.""" - return { - gas_measure_contract: Account(storage=call_contract_post_storage), - } +def modexp_spec(fork: Fork) -> Type[ModExpGasSpec]: + """Return the ModExp gas specification in effect at the given fork.""" + return modexp_spec_at(fork) diff --git a/tests/osaka/eip7883_modexp_gas_increase/spec.py b/tests/osaka/eip7883_modexp_gas_increase/spec.py index 74b72d1b8d6..a4fd4152f64 100644 --- a/tests/osaka/eip7883_modexp_gas_increase/spec.py +++ b/tests/osaka/eip7883_modexp_gas_increase/spec.py @@ -1,8 +1,17 @@ """Defines EIP-7883 specification constants and functions.""" from dataclasses import dataclass +from typing import Type + +from execution_testing import Fork from ...byzantium.eip198_modexp_precompile.helpers import ModExpInput +from ...byzantium.eip198_modexp_precompile.spec import ( + ModExpGasSpec, + Spec2565, + ceiling_division, + modexp_gas_spec, +) @dataclass(frozen=True) @@ -18,28 +27,11 @@ class ReferenceSpec: ) -def ceiling_division(a: int, b: int) -> int: +class Spec(Spec2565): """ - Calculate the ceil without using floating point. Used by many of the EVM's - formulas. + Constants and helpers for the ModExp gas cost calculation, plus the + arbitrary inputs shared by the tests. """ - return -(a // -b) - - -class Spec: - """Constants and helpers for the ModExp gas cost calculation.""" - - MODEXP_ADDRESS = 0x05 - MIN_GAS = 200 - - LARGE_BASE_MODULUS_MULTIPLIER = 1 - MAX_LENGTH_THRESHOLD = 32 - EXPONENT_BYTE_MULTIPLIER = 8 - MAX_LENGTH_BYTES = 1024 - - WORD_SIZE = 8 - EXPONENT_THRESHOLD = 32 - GAS_DIVISOR = 3 # Arbitrary Test Constants modexp_input = ModExpInput( @@ -52,58 +44,6 @@ class Spec: ) modexp_error = bytes() - @classmethod - def calculate_multiplication_complexity( - cls, base_length: int, modulus_length: int - ) -> int: - """Calculate the multiplication complexity of the ModExp precompile.""" - max_length = max(base_length, modulus_length) - words = ceiling_division(max_length, cls.WORD_SIZE) - if max_length <= cls.MAX_LENGTH_THRESHOLD: - return words**2 - return cls.LARGE_BASE_MODULUS_MULTIPLIER * words**2 - - @classmethod - def calculate_iteration_count(cls, modexp_input: ModExpInput) -> int: - """ - Calculate the iteration count of the ModExp precompile. This handles - length mismatch cases by using declared lengths from the raw input and - only the first 32 bytes of exponent data for iteration calculation. - """ - _, exponent_length, _ = modexp_input.get_declared_lengths() - exponent_head = modexp_input.get_exponent_head() - if exponent_length <= cls.EXPONENT_THRESHOLD and exponent_head == 0: - iteration_count = 0 - elif exponent_length <= cls.EXPONENT_THRESHOLD: - iteration_count = ( - exponent_head.bit_length() - 1 if exponent_head > 0 else 0 - ) - else: - # For large exponents: length_part + bits from first 32 bytes - length_part = cls.EXPONENT_BYTE_MULTIPLIER * (exponent_length - 32) - bits_part = ( - exponent_head.bit_length() - 1 if exponent_head > 0 else 0 - ) - iteration_count = length_part + bits_part - return max(iteration_count, 1) - - @classmethod - def calculate_gas_cost(cls, modexp_input: ModExpInput) -> int: - """ - Calculate the ModExp gas cost according to EIP-2565 specification, - overridden by the constants within `Spec7883` when calculating for the - EIP-7883 specification. - """ - base_length, _, modulus_length = modexp_input.get_declared_lengths() - multiplication_complexity = cls.calculate_multiplication_complexity( - base_length, modulus_length - ) - iteration_count = cls.calculate_iteration_count(modexp_input) - return max( - cls.MIN_GAS, - (multiplication_complexity * iteration_count // cls.GAS_DIVISOR), - ) - @dataclass(frozen=True) class Spec7883(Spec): @@ -133,3 +73,8 @@ def calculate_multiplication_complexity( if max_length > cls.MAX_LENGTH_THRESHOLD: complexity = cls.LARGE_BASE_MODULUS_MULTIPLIER * words**2 return complexity + + +def modexp_spec_at(fork: Fork) -> Type[ModExpGasSpec]: + """Return the ModExp gas specification in effect at the given fork.""" + return Spec7883 if fork.is_eip_enabled(7883) else modexp_gas_spec(fork) diff --git a/tests/osaka/eip7883_modexp_gas_increase/test_modexp_thresholds.py b/tests/osaka/eip7883_modexp_gas_increase/test_modexp_thresholds.py index dbc0dfaf14c..c11446772a1 100644 --- a/tests/osaka/eip7883_modexp_gas_increase/test_modexp_thresholds.py +++ b/tests/osaka/eip7883_modexp_gas_increase/test_modexp_thresholds.py @@ -60,7 +60,7 @@ def test_vectors_from_eip( ids=lambda v: v.name, ) @EIPChecklist.Precompile.Test.Inputs.Invalid() -@pytest.mark.valid_from("Berlin") +@pytest.mark.valid_from("Byzantium") def test_vectors_from_legacy_tests( state_test: StateTestFiller, pre: Alloc, @@ -338,7 +338,7 @@ def test_modexp_call_operations( ) @EIPChecklist.Precompile.Test.GasUsage.Dynamic() @EIPChecklist.Precompile.Test.ExcessiveGasUsage() -@pytest.mark.valid_from("Berlin") +@pytest.mark.valid_from("Byzantium") def test_modexp_gas_usage_contract_wrapper( state_test: StateTestFiller, pre: Alloc,