diff --git a/.gitignore b/.gitignore index 827d04d6a69..0ab5c1c2054 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,6 @@ logs/ env.yaml site/ + +# Gas Repricing +gas_repricing.json diff --git a/docs/gas_repricing/reference.md b/docs/gas_repricing/reference.md new file mode 100644 index 00000000000..6f9e0ab69ef --- /dev/null +++ b/docs/gas_repricing/reference.md @@ -0,0 +1,161 @@ +# GasCosts Reference + +This page lists the `GasCosts` fields and the opcodes they affect. Use this as +a reference when creating or editing a `gas_repricing.json` config file to help +you know which field to override. + +For an up-to-date, fork-specific mapping, run: + +```bash +uv run gas-map --fork +``` + +## Base Operation Costs + +| GasCosts Field | Typical Value | Affected Opcodes | +|---|---|---| +| `GAS_VERY_LOW` | 3 | ADD, SUB, CALLDATALOAD, LT, GT, SLT, SGT, EQ, ISZERO, AND, OR, XOR, NOT, BYTE, SHL, SHR, SAR, SIGNEXTEND, PUSH1-PUSH32, DUP1-DUP16, SWAP1-SWAP16, MLOAD, MSTORE, MSTORE8 | +| `GAS_LOW` | 5 | MUL, DIV, SDIV, MOD, SMOD, CLZ | +| `GAS_MID` | 8 | ADDMOD, MULMOD, JUMP | +| `GAS_HIGH` | 10 | JUMPI | +| `GAS_BASE` | 2 | ADDRESS, ORIGIN, CALLER, CALLVALUE, CALLDATASIZE, CODESIZE, GASPRICE, COINBASE, TIMESTAMP, NUMBER, PREVRANDAO, GASLIMIT, POP, PC, MSIZE, GAS, RETURNDATASIZE, CHAINID, SELFBALANCE, BASEFEE, BLOBBASEFEE | +| `GAS_JUMPDEST` | 1 | JUMPDEST | +| `GAS_BLOCK_HASH` | 20 | BLOCKHASH | + +## Storage Costs + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `GAS_WARM_SLOAD` | 100 | SLOAD when slot is warm | +| `GAS_COLD_SLOAD` | 2100 | SLOAD when slot is cold | +| `GAS_STORAGE_SET` | 20000 | SSTORE: setting a slot from zero to non-zero | +| `GAS_STORAGE_UPDATE` | 2900 | SSTORE: updating existing non-zero slot | +| `GAS_STORAGE_RESET` | 2900 | SSTORE: resetting to original value | + +## Account Access Costs + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `GAS_WARM_ACCOUNT_ACCESS` | 100 | BALANCE, EXTCODESIZE, etc. when warm | +| `GAS_COLD_ACCOUNT_ACCESS` | 2600 | BALANCE, EXTCODESIZE, etc. when cold | +| `GAS_TX_ACCESS_LIST_ADDRESS` | 2400 | Per address in access list | +| `GAS_TX_ACCESS_LIST_STORAGE_KEY` | 1900 | Per storage key in access list | + +## Exponentiation + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `GAS_EXPONENTIATION` | 10 | EXP base cost | +| `GAS_EXPONENTIATION_PER_BYTE` | 50 | EXP per byte of exponent | + +## Memory and Copy + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `GAS_MEMORY` | 3 | Memory expansion cost coefficient | +| `GAS_COPY` | 3 | Per-word copy cost (CALLDATACOPY, CODECOPY, etc.) | +| `GAS_KECCAK256` | 30 | SHA3 base cost | +| `GAS_KECCAK256_PER_WORD` | 6 | SHA3 per 32-byte word | + +## Logging + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `GAS_LOG` | 375 | LOG base cost | +| `GAS_LOG_DATA_PER_BYTE` | 8 | LOG per byte of data | +| `GAS_LOG_TOPIC` | 375 | LOG per topic | + +## Transaction Costs + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `GAS_TX_BASE` | 21000 | Base transaction cost | +| `GAS_TX_CREATE` | 32000 | Additional cost for contract creation tx | +| `GAS_TX_DATA_PER_ZERO` | 4 | Per zero byte in tx data | +| `GAS_TX_DATA_PER_NON_ZERO` | 16 | Per non-zero byte in tx data | +| `GAS_TX_DATA_TOKEN_STANDARD` | 4 | Token cost per data element | +| `GAS_TX_DATA_TOKEN_FLOOR` | 0 | Minimum token cost | + +## Call and Create + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `GAS_CALL_VALUE` | 9000 | Additional cost when transferring value | +| `GAS_CALL_STIPEND` | 2300 | Gas stipend for calls with value | +| `GAS_NEW_ACCOUNT` | 25000 | Creating a new account via call | +| `GAS_CREATE` | 32000 | CREATE opcode base cost | +| `GAS_CODE_DEPOSIT_PER_BYTE` | 200 | Per byte of deployed code | +| `GAS_CODE_INIT_PER_WORD` | 2 | Per word of init code (EIP-3860) | +| `GAS_SELF_DESTRUCT` | 5000 | SELFDESTRUCT base cost | + +## Auth (EIP-3074) + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `GAS_AUTH_PER_EMPTY_ACCOUNT` | 25000 | AUTH cost for empty account | + +## Precompile Costs + +| GasCosts Field | Typical Value | Precompile | +|---|---|---| +| `GAS_PRECOMPILE_ECRECOVER` | 3000 | ecRecover (0x01) | +| `GAS_PRECOMPILE_SHA256_BASE` | 60 | SHA-256 base (0x02) | +| `GAS_PRECOMPILE_SHA256_PER_WORD` | 12 | SHA-256 per word (0x02) | +| `GAS_PRECOMPILE_RIPEMD160_BASE` | 600 | RIPEMD-160 base (0x03) | +| `GAS_PRECOMPILE_RIPEMD160_PER_WORD` | 120 | RIPEMD-160 per word (0x03) | +| `GAS_PRECOMPILE_IDENTITY_BASE` | 15 | Identity base (0x04) | +| `GAS_PRECOMPILE_IDENTITY_PER_WORD` | 3 | Identity per word (0x04) | +| `GAS_PRECOMPILE_ECADD` | 150 | BN256 add (0x06) | +| `GAS_PRECOMPILE_ECMUL` | 6000 | BN256 mul (0x07) | +| `GAS_PRECOMPILE_ECPAIRING_BASE` | 45000 | BN256 pairing base (0x08) | +| `GAS_PRECOMPILE_ECPAIRING_PER_POINT` | 34000 | BN256 pairing per point (0x08) | +| `GAS_PRECOMPILE_BLAKE2F_BASE` | 0 | BLAKE2 base (0x09) | +| `GAS_PRECOMPILE_BLAKE2F_PER_ROUND` | 1 | BLAKE2 per round (0x09) | +| `GAS_PRECOMPILE_POINT_EVALUATION` | 50000 | Point evaluation (0x0a) | +| `GAS_PRECOMPILE_BLS_G1ADD` | 500 | BLS G1 add (0x0b) | +| `GAS_PRECOMPILE_BLS_G1MUL` | 12000 | BLS G1 mul (0x0c) | +| `GAS_PRECOMPILE_BLS_G1MAP` | 5500 | BLS G1 map (0x12) | +| `GAS_PRECOMPILE_BLS_G2ADD` | 800 | BLS G2 add (0x0d) | +| `GAS_PRECOMPILE_BLS_G2MUL` | 45000 | BLS G2 mul (0x0e) | +| `GAS_PRECOMPILE_BLS_G2MAP` | 110000 | BLS G2 map (0x13) | +| `GAS_PRECOMPILE_BLS_PAIRING_BASE` | 115000 | BLS pairing base (0x11) | +| `GAS_PRECOMPILE_BLS_PAIRING_PER_PAIR` | 23000 | BLS pairing per pair (0x11) | +| `GAS_PRECOMPILE_P256VERIFY` | 6900 | P256 verify (0x100) | + +## Refund Constants + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `REFUND_STORAGE_CLEAR` | 4800 | Refund for clearing a storage slot | +| `REFUND_AUTH_PER_EXISTING_ACCOUNT` | 25000 | AUTH refund for existing account | + +## Dynamic Opcodes + +Some opcodes have dynamic gas costs that depend on multiple `GasCosts` fields +and runtime context: + +| Opcode | Relevant GasCosts Fields | Notes | +|---|---|---| +| EXP | `GAS_EXPONENTIATION`, `GAS_EXPONENTIATION_PER_BYTE` | Cost depends on exponent size | +| SLOAD | `GAS_WARM_SLOAD`, `GAS_COLD_SLOAD` | Warm vs cold access | +| SSTORE | `GAS_STORAGE_SET`, `GAS_STORAGE_UPDATE`, `GAS_STORAGE_RESET`, `GAS_WARM_SLOAD`, `GAS_COLD_SLOAD` | Complex rules based on original/current/new values | +| SHA3 | `GAS_KECCAK256`, `GAS_KECCAK256_PER_WORD` | Base + per-word cost | +| LOG0-LOG4 | `GAS_LOG`, `GAS_LOG_DATA_PER_BYTE`, `GAS_LOG_TOPIC` | Base + data + topics | +| CALL/CALLCODE | `GAS_WARM_ACCOUNT_ACCESS`, `GAS_COLD_ACCOUNT_ACCESS`, `GAS_CALL_VALUE`, `GAS_NEW_ACCOUNT` | Complex rules based on account state | +| CREATE/CREATE2 | `GAS_CREATE`, `GAS_CODE_INIT_PER_WORD` | Base + init code cost | +| BALANCE/EXTCODESIZE | `GAS_WARM_ACCOUNT_ACCESS`, `GAS_COLD_ACCOUNT_ACCESS` | Warm vs cold access | +| SELFDESTRUCT | `GAS_SELF_DESTRUCT`, `GAS_COLD_ACCOUNT_ACCESS`, `GAS_NEW_ACCOUNT` | Depends on target account state | + +## Generating Up-to-Date Mappings + +The tables above reflect typical values. Exact values vary by fork. + +For the authoritative mapping for a specific fork: + +```bash +# Full mapping +uv run gas-map --fork Osaka + +# Single opcode detail +uv run gas-map --opcode SLOAD --fork Osaka +``` diff --git a/docs/gas_repricing/repricing_guide.md b/docs/gas_repricing/repricing_guide.md new file mode 100644 index 00000000000..5fa1b1b5cfd --- /dev/null +++ b/docs/gas_repricing/repricing_guide.md @@ -0,0 +1,89 @@ +# Gas Repricing Guide + +## What is Gas Repricing? + +Gas repricing allows you to override the default gas cost constants for any fork +without modifying source code. This is useful for: + +- Experimenting with alternative gas schedules +- Testing the impact of proposed EIP gas changes +- Running "what-if" analyses on existing test suites + +## JSON Config Format + +Create a JSON file mapping fork names to `GasCosts` field overrides: + +```json +{ + "Osaka": { + "GAS_VERY_LOW": 4, + "GAS_COLD_SLOAD": 2200 + }, + "Prague": { + "GAS_WARM_SLOAD": 150 + } +} +``` + +Each key is a fork name (e.g., `Osaka`, `Prague`, `Cancun`). Each value is an +object mapping `GasCosts` field names to their new integer values. Only the +fields you want to change need to be specified; all others retain their +defaults. + +## Activation + +Set the `EELS_GAS_REPRICING_CONFIG` environment variable to the path of your +JSON config file: + +```bash +export EELS_GAS_REPRICING_CONFIG=./my_gas_repricing.json +uv run fill tests/osaka/ +``` + +The repricing config is loaded once (cached) and applied transparently whenever +`gas_costs()` is called on a fork. + +## Finding the Right Field Names + +The `GasCosts` dataclass has ~90 fields. To find which field controls a +particular opcode's gas cost, use the `gas-map` CLI tool: + +```bash +# Show full mapping for a fork +uv run gas-map --fork Osaka + +# Look up a specific opcode +uv run gas-map --opcode SLOAD +``` + +See [GasCosts Reference](reference.md) for a static reference table. + +## Example Workflow + +1. Identify the opcodes you want to reprice: + + ```bash + uv run gas-map --opcode SLOAD + ``` + + Output shows `GAS_WARM_SLOAD` and `GAS_COLD_SLOAD` are the relevant fields. + +2. Create a repricing config: + + ```json + { + "Osaka": { + "GAS_WARM_SLOAD": 150, + "GAS_COLD_SLOAD": 2500 + } + } + ``` + +3. Run tests with the new gas schedule: + + ```bash + EELS_GAS_REPRICING_CONFIG=./reprice.json uv run fill tests/osaka/ + ``` + +4. Compare results against the baseline to see which tests break or change + behavior under the new gas schedule. diff --git a/docs/navigation.md b/docs/navigation.md index 002b2b94aa5..87652ff0b64 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -32,6 +32,9 @@ * [Adding a Blockchain Test](writing_tests/tutorials/blockchain.md) * [Opcode Metadata](writing_tests/opcode_metadata.md) * [Porting Legacy Tests](writing_tests/porting_legacy_tests.md) + * Gas Repricing + * [Repricing Guide](gas_repricing/repricing_guide.md) + * [GasCosts Reference](gas_repricing/reference.md) * [Filling Tests](filling_tests/index.md) * [Getting Started](filling_tests/getting_started.md) * [Filling Tests at a Prompt](filling_tests/filling_tests_command_line.md) diff --git a/packages/testing/pyproject.toml b/packages/testing/pyproject.toml index f9b5960a7ab..eb95700968c 100644 --- a/packages/testing/pyproject.toml +++ b/packages/testing/pyproject.toml @@ -94,6 +94,7 @@ order_fixtures = "execution_testing.cli.order_fixtures:order_fixtures" evm_bytes = "execution_testing.cli.evm_bytes:evm_bytes" hasher = "execution_testing.cli.hasher:main" eest = "execution_testing.cli.eest.cli:eest" +gas-map = "execution_testing.cli.eest.commands.gas_map:gas_map" fillerconvert = "execution_testing.cli.fillerconvert.fillerconvert:main" groupstats = "execution_testing.cli.show_pre_alloc_group_stats:main" extract_config = "execution_testing.cli.extract_config:extract_config" diff --git a/packages/testing/src/execution_testing/cli/eest/cli.py b/packages/testing/src/execution_testing/cli/eest/cli.py index b93469a5cb1..af2d4d1bf1c 100644 --- a/packages/testing/src/execution_testing/cli/eest/cli.py +++ b/packages/testing/src/execution_testing/cli/eest/cli.py @@ -6,6 +6,7 @@ import click from .commands import clean, info +from .commands.gas_map import gas_map from .make.cli import make @@ -33,3 +34,4 @@ def eest() -> None: eest.add_command(make) eest.add_command(clean) eest.add_command(info) +eest.add_command(gas_map, name="gas-map") diff --git a/packages/testing/src/execution_testing/cli/eest/commands/gas_map.py b/packages/testing/src/execution_testing/cli/eest/commands/gas_map.py new file mode 100644 index 00000000000..1bbe913fa2a --- /dev/null +++ b/packages/testing/src/execution_testing/cli/eest/commands/gas_map.py @@ -0,0 +1,300 @@ +"""Display the mapping between EVM opcodes and GasCosts field names.""" + +import inspect +import re +from collections import defaultdict +from dataclasses import fields + +import click + +from execution_testing.forks.base_fork import BaseFork +from execution_testing.forks.gas_costs import GasCosts +from execution_testing.forks.helpers import get_forks + +OPCODE_TIER_FIELDS = ( + "GAS_JUMPDEST", + "GAS_BASE", + "GAS_VERY_LOW", + "GAS_LOW", + "GAS_MID", + "GAS_HIGH", + "GAS_BLOCK_HASH", + "GAS_WARM_SLOAD", +) + + +def _get_latest_fork() -> type[BaseFork]: + """Return the latest fork class.""" + return get_forks()[-1] + + +def _get_fork(fork_name: str) -> type[BaseFork]: + """Return the fork class matching fork_name, or exit with error.""" + for fork in get_forks(): + if fork.name().lower() == fork_name.lower(): + return fork + available = ", ".join(f.name() for f in get_forks()) + raise click.ClickException( + f"Unknown fork: {fork_name}. Available forks: {available}" + ) + + +def _build_tier_reverse_map(gas_costs: GasCosts) -> dict[int, list[str]]: + """Build a reverse map from gas value to opcode tier field names only.""" + reverse = defaultdict(list) + for name in OPCODE_TIER_FIELDS: + value = getattr(gas_costs, name) + reverse[value].append(name) + return dict(reverse) + + +def _get_opcode_gas_map_sources(fork_class: type[BaseFork]) -> str: + """Get source code of opcode_gas_map from the fork's MRO chain.""" + sources = [] + for cls in fork_class.__mro__: + if cls is BaseFork or cls is object: + continue + if "opcode_gas_map" in cls.__dict__: + try: + # `cls` is typed as `type` (from `__mro__`) + # The full chain up to python's default `object` is walked, and + # the guard above ensures that opcode_gas_map exists before + # adding the source. + method = cls.opcode_gas_map # type: ignore[attr-defined] + sources.append(inspect.getsource(method)) + except (OSError, TypeError): + pass + return "\n".join(sources) + + +def _get_helper_method_fields( + fork_class: type[BaseFork], +) -> dict[str, set[str]]: + """Extract GasCosts fields from helper methods on the fork class.""" + valid_fields = {f.name for f in fields(GasCosts)} + helper_fields = {} + for cls in fork_class.__mro__: + if cls is object: + continue + for name, method in cls.__dict__.items(): + if name.startswith("_with_") or name.startswith("_calculate_"): + try: + src = inspect.getsource(method) + except (OSError, TypeError): + continue + found = set() + for field_name in valid_fields: + if field_name in src: + found.add(field_name) + if name == "_with_memory_expansion": + found.add("GAS_MEMORY") + if found: + helper_fields[name] = found + return helper_fields + + +def _build_full_opcode_field_map( + fork_class: type[BaseFork], +) -> dict[str, list[str]]: + """Build complete opcode→GasCosts fields map using source analysis.""" + source = _get_opcode_gas_map_sources(fork_class) + valid_fields = {f.name for f in fields(GasCosts)} + helper_fields = _get_helper_method_fields(fork_class) + opcode_fields = defaultdict(set) + + lines_iter = iter(source.split("\n")) + opcode_re = re.compile(r"Opcodes\.(\w+)") + field_re = re.compile(r"gas_costs\.(\w+)") + helper_re = re.compile(r"cls\.(_with_\w+|_calculate_\w+)") + + current_opcode = None + brace_depth = 0 + + for line in lines_iter: + opcode_match = opcode_re.search(line) + if opcode_match: + current_opcode = opcode_match.group(1) + + if current_opcode: + for fm in field_re.finditer(line): + fn = fm.group(1) + if fn in valid_fields: + opcode_fields[current_opcode].add(fn) + + for hm in helper_re.finditer(line): + hname = hm.group(1) + if hname in helper_fields: + opcode_fields[current_opcode].update(helper_fields[hname]) + + brace_depth += line.count("(") - line.count(")") + if current_opcode and brace_depth <= 0 and "," in line: + current_opcode = None + brace_depth = 0 + + return {k: sorted(v) for k, v in opcode_fields.items()} + + +def _format_grouped_output(fork_class: type[BaseFork]) -> str: + """Format the full grouped-by-GasCosts-field output.""" + fork_name = fork_class.name() + gas_costs = fork_class.gas_costs() + opcode_map = fork_class.opcode_gas_map() + tier_reverse = _build_tier_reverse_map(gas_costs) + source_fields = _build_full_opcode_field_map(fork_class) + + field_to_opcodes = defaultdict(list) + dynamic_opcodes = [] + constant_opcodes = [] + + for opcode, cost in opcode_map.items(): + name = opcode._name_ + if callable(cost): + gas_fields = source_fields.get(name, []) + dynamic_opcodes.append((name, gas_fields)) + elif cost in tier_reverse: + for field_name in tier_reverse[cost]: + field_to_opcodes[field_name].append(name) + else: + constant_opcodes.append((name, cost)) + + lines = [ + f"Opcode-to-GasCosts mapping for {fork_name}", + "\u2550" * 50, + "", + ] + + for field_name in OPCODE_TIER_FIELDS: + if field_name not in field_to_opcodes: + continue + value = getattr(gas_costs, field_name) + opcodes = field_to_opcodes[field_name] + lines.append(f"{field_name} ({value})") + line = " " + for i, op in enumerate(opcodes): + suffix = ", " if i < len(opcodes) - 1 else "" + if len(line) + len(op) + len(suffix) > 72: + lines.append(line.rstrip(", ")) + line = " " + line += op + suffix + if line.strip(): + lines.append(line.rstrip(", ")) + lines.append("") + + if dynamic_opcodes: + lines.append("Dynamic (multiple GasCosts fields)") + for name, gas_fields in sorted(dynamic_opcodes): + if gas_fields: + fields_str = ", ".join(gas_fields) + lines.append(f" {name:<18}{fields_str}") + else: + lines.append(f" {name:<18}(unknown)") + lines.append("") + + if constant_opcodes: + lines.append("Constants (no GasCosts field)") + for name, value in sorted(constant_opcodes): + lines.append(f" {name:<18}{value}") + lines.append("") + + return "\n".join(lines) + + +def _format_single_opcode(fork_class: type[BaseFork], opcode_name: str) -> str: + """Format detailed output for a single opcode.""" + fork_name = fork_class.name() + gas_costs = fork_class.gas_costs() + opcode_map = fork_class.opcode_gas_map() + tier_reverse = _build_tier_reverse_map(gas_costs) + source_fields = _build_full_opcode_field_map(fork_class) + + target = None + for opcode in opcode_map: + if opcode._name_.upper() == opcode_name.upper(): + target = opcode + break + + if target is None: + available = sorted(op._name_ for op in opcode_map) + raise click.ClickException( + f"Unknown opcode: {opcode_name}. Available: {', '.join(available)}" + ) + + cost = opcode_map[target] + name = target._name_ + lines = [ + f"{name} \u2014 {fork_name}", + "\u2550" * 30, + ] + + if callable(cost): + gas_fields = source_fields.get(name, []) + lines.append("Type: dynamic") + if gas_fields: + parts = [] + for gf in gas_fields: + val = getattr(gas_costs, gf) + parts.append(f"{gf} ({val})") + lines.append(f"GasCosts: {', '.join(parts)}") + lines.append("") + lines.append("To reprice in gas_repricing.json:") + lines.append("{") + lines.append(f' "{fork_name}": {{') + for gf in gas_fields: + lines.append(f' "{gf}": ,') + lines.append(" }") + lines.append("}") + elif cost in tier_reverse: + field_names = tier_reverse[cost] + lines.append("Type: static") + parts = [f"{fn} ({cost})" for fn in field_names] + lines.append(f"GasCosts: {', '.join(parts)}") + lines.append("") + lines.append("To reprice in gas_repricing.json:") + lines.append("{") + lines.append(f' "{fork_name}": {{') + for fn in field_names: + lines.append(f' "{fn}": ,') + lines.append(" }") + lines.append("}") + else: + lines.append("Type: constant") + lines.append(f"Value: {cost}") + lines.append("") + lines.append( + "This opcode has a fixed cost not tied to a GasCosts field." + ) + + return "\n".join(lines) + + +@click.command( + name="gas-map", + short_help="Display opcode-to-GasCosts field mapping.", +) +@click.option( + "--fork", + "-f", + "fork_name", + default=None, + help="Fork name (default: latest fork).", +) +@click.option( + "--opcode", + "-o", + "opcode_name", + default=None, + help="Show detail for a single opcode.", +) +def gas_map(fork_name: str | None, opcode_name: str | None) -> None: + """Display the mapping between EVM opcodes and GasCosts field names.""" + if fork_name: + fork_class = _get_fork(fork_name) + else: + fork_class = _get_latest_fork() + + if opcode_name: + output = _format_single_opcode(fork_class, opcode_name) + else: + output = _format_grouped_output(fork_class) + + click.echo(output) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 269640e2985..6593e08740a 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -357,9 +357,26 @@ def empty_block_bal_item_count(cls) -> int: # Gas related abstract methods @classmethod - @abstractmethod - def gas_costs(cls) -> GasCosts: + def gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Return dataclass with the gas costs constants for the fork.""" + from .gas_repricing import apply_repricing + + base = cls._base_gas_costs( + block_number=block_number, timestamp=timestamp + ) + fork_name = cls.fork_at( + block_number=block_number, timestamp=timestamp + ).name() + return apply_repricing(fork_name, base) + + @classmethod + @abstractmethod + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: + """Return base gas costs before repricing overrides.""" pass @classmethod diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index b74c52d071a..19a656c8212 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -105,10 +105,13 @@ def header_blob_gas_used_required(cls) -> bool: return False @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """ Return dataclass with the defined gas costs constants for genesis. """ + del block_number, timestamp return GasCosts( GAS_JUMPDEST=1, GAS_BASE=2, @@ -1478,12 +1481,16 @@ def valid_opcodes(cls) -> List[Opcodes]: ] + super(Byzantium, cls).valid_opcodes() @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """ On Byzantium, precompiled contract gas costs are introduced. """ return replace( - super(Byzantium, cls).gas_costs(), + super(Byzantium, cls)._base_gas_costs( + block_number=block_number, timestamp=timestamp + ), GAS_PRECOMPILE_ECADD=500, GAS_PRECOMPILE_ECMUL=40_000, GAS_PRECOMPILE_ECPAIRING_BASE=100_000, @@ -1594,13 +1601,17 @@ def valid_opcodes(cls) -> List[Opcodes]: ).valid_opcodes() @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """ On Istanbul, the non-zero transaction data byte cost is reduced to 16 due to EIP-2028. """ return replace( - super(Istanbul, cls).gas_costs(), + super(Istanbul, cls)._base_gas_costs( + block_number=block_number, timestamp=timestamp + ), GAS_TX_DATA_PER_NON_ZERO=16, # https://eips.ethereum.org/EIPS/eip-2028 # https://eips.ethereum.org/EIPS/eip-1108 GAS_PRECOMPILE_ECADD=150, @@ -2210,10 +2221,14 @@ def engine_new_payload_beacon_root(cls) -> bool: return True @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """On Cancun, the point evaluation precompile gas cost is set.""" return replace( - super(Cancun, cls).gas_costs(), + super(Cancun, cls)._base_gas_costs( + block_number=block_number, timestamp=timestamp + ), GAS_PRECOMPILE_POINT_EVALUATION=50_000, ) @@ -2304,13 +2319,17 @@ def tx_types(cls) -> List[int]: return [4] + super(Prague, cls).tx_types() @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """ On Prague, the standard token cost and the floor token costs are introduced due to EIP-7623. """ return replace( - super(Prague, cls).gas_costs(), + super(Prague, cls)._base_gas_costs( + block_number=block_number, timestamp=timestamp + ), GAS_TX_DATA_TOKEN_STANDARD=4, # https://eips.ethereum.org/EIPS/eip-7623 GAS_TX_DATA_TOKEN_FLOOR=10, GAS_AUTH_PER_EMPTY_ACCOUNT=25_000, @@ -2649,10 +2668,14 @@ def precompiles(cls) -> List[Address]: ] + super(Osaka, cls).precompiles() @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """On Osaka, the P256VERIFY precompile gas cost is set.""" return replace( - super(Osaka, cls).gas_costs(), + super(Osaka, cls)._base_gas_costs( + block_number=block_number, timestamp=timestamp + ), GAS_PRECOMPILE_P256VERIFY=6_900, ) @@ -2849,13 +2872,17 @@ def header_bal_hash_required(cls) -> bool: return True @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """ On Amsterdam, the cost per block access list item is introduced in EIP-7928. """ return replace( - super(Amsterdam, cls).gas_costs(), + super(Amsterdam, cls)._base_gas_costs( + block_number=block_number, timestamp=timestamp + ), GAS_BLOCK_ACCESS_LIST_ITEM=2000, ) diff --git a/packages/testing/src/execution_testing/forks/gas_repricing.py b/packages/testing/src/execution_testing/forks/gas_repricing.py new file mode 100644 index 00000000000..69f0c0b84b5 --- /dev/null +++ b/packages/testing/src/execution_testing/forks/gas_repricing.py @@ -0,0 +1,43 @@ +"""Gas repricing override loader for fast iteration on gas schedules.""" + +from dataclasses import fields, replace + +from ethereum.utils.gas_repricing import load_repricing_config + +from .gas_costs import GasCosts + +_VALID_FIELDS = frozenset(f.name for f in fields(GasCosts)) + + +def _validate_overrides( + fork_name: str, + overrides: dict, +) -> None: + for field_name, value in overrides.items(): + if field_name not in _VALID_FIELDS: + raise ValueError( + f"Unknown GasCosts field '{field_name}' " + f"in repricing config for fork " + f"'{fork_name}'. " + f"Valid fields: {sorted(_VALID_FIELDS)}" + ) + if not isinstance(value, int): + raise TypeError( + f"GasCosts field '{field_name}' for fork " + f"'{fork_name}' must be of type int, " + f"got {type(value).__name__}: {value!r}" + ) + + +def apply_repricing(fork_name: str, base_costs: GasCosts) -> GasCosts: + """Apply repricing overrides for fork_name to base_costs.""" + config = load_repricing_config() + if config is None: + return base_costs + + overrides = config.get(fork_name) + if overrides is None: + return base_costs + + _validate_overrides(fork_name, overrides) + return replace(base_costs, **overrides) diff --git a/packages/testing/src/execution_testing/forks/tests/test_gas_repricing.py b/packages/testing/src/execution_testing/forks/tests/test_gas_repricing.py new file mode 100644 index 00000000000..234aa602937 --- /dev/null +++ b/packages/testing/src/execution_testing/forks/tests/test_gas_repricing.py @@ -0,0 +1,305 @@ +"""Tests for gas repricing override mechanism.""" + +import json +from collections.abc import Generator +from pathlib import Path + +import pytest +from ethereum.utils.gas_repricing import ( + _ENV_VAR, + apply_spec_repricing, + load_repricing_config, +) + +from ..forks.forks import Osaka, Prague +from ..forks.transition import PragueToOsakaAtTime15k +from ..gas_costs import GasCosts +from ..gas_repricing import apply_repricing + + +@pytest.fixture(autouse=True) +def _clear_repricing_cache( + monkeypatch: pytest.MonkeyPatch, +) -> Generator[None, None, None]: + """Clear the lru_cache and env var before and after each test.""" + load_repricing_config.cache_clear() + monkeypatch.delenv(_ENV_VAR, raising=False) + yield + load_repricing_config.cache_clear() + + +def _default_osaka_costs() -> GasCosts: + return Osaka._base_gas_costs() + + +class TestLoadRepricingConfig: + """Tests for load_repricing_config.""" + + def test_no_env_var(self) -> None: + """Test that the config is not loaded when the env var is unset.""" + config = load_repricing_config() + assert config is None + + def test_empty_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that the config is not loaded when the env var is null.""" + monkeypatch.setenv(_ENV_VAR, "") + config = load_repricing_config() + assert config is None + + def test_missing_file(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that an error is raised when config is missing.""" + monkeypatch.setenv(_ENV_VAR, "/nonexistent/path.json") + with pytest.raises(FileNotFoundError): + load_repricing_config() + + def test_valid_config_with_unknown_field( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test that unknown fields pass the shared loader.""" + config_file = tmp_path / "unknown.json" + config_file.write_text( + json.dumps({"Osaka": {"NOT_A_REAL_FIELD": 999}}) + ) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + with pytest.warns(UserWarning): + config = load_repricing_config() + assert config is not None + + def test_valid_config( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test that a minimal valid config loads correctly.""" + config_file = tmp_path / "good.json" + config_file.write_text(json.dumps({"Osaka": {"GAS_TX_BASE": 25000}})) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + config = load_repricing_config() + assert config == {"Osaka": {"GAS_TX_BASE": 25000}} + + def test_warning_emitted( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test that warnings are emitted when repricing has taken place.""" + config_file = tmp_path / "warn.json" + config_file.write_text(json.dumps({"Osaka": {"GAS_TX_BASE": 1}})) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + with pytest.warns(UserWarning, match="Gas repricing config loaded"): + load_repricing_config() + + +class TestApplyRepricing: + """Tests for apply_repricing.""" + + def test_no_config(self) -> None: + """ + Test that costs are not altered when no repricing has taken place. + """ + base = _default_osaka_costs() + result = apply_repricing("Osaka", base) + assert result is base + + def test_fork_not_in_config( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """ + Test that applying repricing for a different fork does not affect the + values in Osaka. + """ + config_file = tmp_path / "other.json" + config_file.write_text( + json.dumps({"Amsterdam": {"GAS_TX_BASE": 25000}}) + ) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + base = _default_osaka_costs() + with pytest.warns(UserWarning): + result = apply_repricing("Osaka", base) + assert result is base + + def test_single_field_override( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """ + Test that repricing a single field does not affect the other fields. + """ + config_file = tmp_path / "single.json" + config_file.write_text(json.dumps({"Osaka": {"GAS_TX_BASE": 99999}})) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + base = _default_osaka_costs() + with pytest.warns(UserWarning): + result = apply_repricing("Osaka", base) + assert result.GAS_TX_BASE == 99999 + assert result.GAS_COLD_ACCOUNT_ACCESS == base.GAS_COLD_ACCOUNT_ACCESS + + def test_invalid_field_name( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Test that an unknown field raises ValueError.""" + config_file = tmp_path / "bad.json" + config_file.write_text( + json.dumps({"Osaka": {"NOT_A_REAL_FIELD": 999}}) + ) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + base = _default_osaka_costs() + with pytest.warns(UserWarning): + with pytest.raises(ValueError, match="NOT_A_REAL_FIELD"): + apply_repricing("Osaka", base) + + def test_non_int_value_type( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Test that a non-int value raises TypeError.""" + config_file = tmp_path / "bad_type.json" + config_file.write_text( + json.dumps({"Osaka": {"GAS_TX_BASE": "not_a_number"}}) + ) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + base = _default_osaka_costs() + with pytest.warns(UserWarning): + with pytest.raises(TypeError, match="must be of type int"): + apply_repricing("Osaka", base) + + +class TestIntegration: + """Integration tests using the full gas_costs() path.""" + + def test_osaka_gas_costs_with_override( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Test that Osaka gas costs are properly overwritten by repricing.""" + config_file = tmp_path / "osaka.json" + config_file.write_text( + json.dumps({"Osaka": {"GAS_COLD_ACCOUNT_ACCESS": 2100}}) + ) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + with pytest.warns(UserWarning): + costs = Osaka.gas_costs() + assert costs.GAS_COLD_ACCOUNT_ACCESS == 2100 + assert costs.GAS_TX_BASE == _default_osaka_costs().GAS_TX_BASE + + def test_osaka_gas_costs_without_override(self) -> None: + """ + Test that default Osaka gas costs are not overwritten if gas costs + are not repriced. + """ + costs = Osaka.gas_costs() + assert costs == _default_osaka_costs() + + def test_transition_fork_with_override( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """ + Test repricing during fork transition. + """ + config_file = tmp_path / "transition.json" + config_file.write_text(json.dumps({"Osaka": {"GAS_TX_BASE": 50000}})) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + with pytest.warns(UserWarning): + costs = PragueToOsakaAtTime15k.gas_costs(timestamp=15000) + assert costs.GAS_TX_BASE == 50000 + + def test_transition_fork_pre_transition( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """ + Test that repricing a value in Osaka does not affect pre-transition + prague cost. + """ + config_file = tmp_path / "transition.json" + config_file.write_text(json.dumps({"Osaka": {"GAS_TX_BASE": 50000}})) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + with pytest.warns(UserWarning): + costs = PragueToOsakaAtTime15k.gas_costs(timestamp=0) + assert costs.GAS_TX_BASE == Prague._base_gas_costs().GAS_TX_BASE + + +class TestSpecSideRepricing: + """Tests for apply_spec_repricing (module globals mutation).""" + + def _make_globals(self) -> dict: + from ethereum_types.numeric import U64, Uint + + return { + "GAS_BASE": Uint(2), + "GAS_LOW": Uint(5), + "BLOB_SCHEDULE_TARGET": U64(6), + "__name__": "test_module", + } + + def test_no_config(self) -> None: + """Test no-op when env var is unset.""" + globs = self._make_globals() + original = dict(globs) + apply_spec_repricing("TestFork", globs) + assert globs == original + + def test_fork_not_in_config( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Test no-op when fork is absent from config.""" + config_file = tmp_path / "other_fork.json" + config_file.write_text(json.dumps({"OtherFork": {"GAS_BASE": 99}})) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + globs = self._make_globals() + original = dict(globs) + with pytest.warns(UserWarning): + apply_spec_repricing("TestFork", globs) + assert globs == original + + def test_mutates_with_correct_type( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Test that overrides preserve Uint/U64 type wrappers.""" + from ethereum_types.numeric import U64, Uint + + config_file = tmp_path / "typed.json" + config_file.write_text( + json.dumps( + { + "TestFork": { + "GAS_BASE": 99, + "BLOB_SCHEDULE_TARGET": 12, + } + } + ) + ) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + globs = self._make_globals() + with pytest.warns(UserWarning): + apply_spec_repricing("TestFork", globs) + assert globs["GAS_BASE"] == Uint(99) + assert isinstance(globs["GAS_BASE"], Uint) + assert globs["BLOB_SCHEDULE_TARGET"] == U64(12) + assert isinstance(globs["BLOB_SCHEDULE_TARGET"], U64) + assert globs["GAS_LOW"] == Uint(5) + + def test_unknown_field_raises( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Test that unknown constant names raise ValueError.""" + config_file = tmp_path / "bad_spec.json" + config_file.write_text( + json.dumps({"TestFork": {"NOT_A_CONSTANT": 42}}) + ) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + globs = self._make_globals() + with pytest.warns(UserWarning): + with pytest.raises(ValueError, match="NOT_A_CONSTANT"): + apply_spec_repricing("TestFork", globs) + + def test_nonexistent_file_raises( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that a missing config file raises FileNotFoundError.""" + monkeypatch.setenv(_ENV_VAR, "/nonexistent/config.json") + globs = self._make_globals() + with pytest.raises(FileNotFoundError): + apply_spec_repricing("TestFork", globs) diff --git a/packages/testing/src/execution_testing/forks/transition_base_fork.py b/packages/testing/src/execution_testing/forks/transition_base_fork.py index f67b5005bd8..ea17f2b5e87 100644 --- a/packages/testing/src/execution_testing/forks/transition_base_fork.py +++ b/packages/testing/src/execution_testing/forks/transition_base_fork.py @@ -2,7 +2,7 @@ from typing import Any, Callable, ClassVar, Dict, Type -from .base_fork import BaseFork +from .base_fork import BaseFork, GasCosts class TransitionBaseMetaClass(type): @@ -108,6 +108,17 @@ def ruleset(cls) -> Dict[str, int]: """ raise Exception("Not implemented") + @classmethod + def gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: + """ + Return Gas Costs for the active fork. + """ + return cls.fork_at( + block_number=block_number, timestamp=timestamp + ).gas_costs(block_number=block_number, timestamp=timestamp) + def transition_fork( to_fork: Type[BaseFork], diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index 6807cba420b..7ba1e457929 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -18,6 +18,7 @@ from ethereum.forks.bpo5.blocks import Header as PreviousHeader from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32, taylor_exponential from ..blocks import Header @@ -416,3 +417,6 @@ def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( excess_blob_gas ) + + +apply_spec_repricing("Amsterdam", globals()) diff --git a/src/ethereum/forks/arrow_glacier/vm/gas.py b/src/ethereum/forks/arrow_glacier/vm/gas.py index 7ac4146d8cf..046ad019e13 100644 --- a/src/ethereum/forks/arrow_glacier/vm/gas.py +++ b/src/ethereum/forks/arrow_glacier/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -243,3 +244,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("ArrowGlacier", globals()) diff --git a/src/ethereum/forks/berlin/vm/gas.py b/src/ethereum/forks/berlin/vm/gas.py index 80441f0e4fd..55405074082 100644 --- a/src/ethereum/forks/berlin/vm/gas.py +++ b/src/ethereum/forks/berlin/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -244,3 +245,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("Berlin", globals()) diff --git a/src/ethereum/forks/bpo1/vm/gas.py b/src/ethereum/forks/bpo1/vm/gas.py index 51b439cd0a0..a01b24d9036 100644 --- a/src/ethereum/forks/bpo1/vm/gas.py +++ b/src/ethereum/forks/bpo1/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U64, U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32, taylor_exponential from ..blocks import Header @@ -394,3 +395,6 @@ def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( excess_blob_gas ) + + +apply_spec_repricing("BPO1", globals()) diff --git a/src/ethereum/forks/bpo2/vm/gas.py b/src/ethereum/forks/bpo2/vm/gas.py index 5c30cd6a340..6fba63ec6b4 100644 --- a/src/ethereum/forks/bpo2/vm/gas.py +++ b/src/ethereum/forks/bpo2/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U64, U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32, taylor_exponential from ..blocks import Header @@ -394,3 +395,6 @@ def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( excess_blob_gas ) + + +apply_spec_repricing("BPO2", globals()) diff --git a/src/ethereum/forks/bpo3/vm/gas.py b/src/ethereum/forks/bpo3/vm/gas.py index 5c30cd6a340..96ade588f10 100644 --- a/src/ethereum/forks/bpo3/vm/gas.py +++ b/src/ethereum/forks/bpo3/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U64, U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32, taylor_exponential from ..blocks import Header @@ -394,3 +395,6 @@ def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( excess_blob_gas ) + + +apply_spec_repricing("BPO3", globals()) diff --git a/src/ethereum/forks/bpo4/vm/gas.py b/src/ethereum/forks/bpo4/vm/gas.py index 5c30cd6a340..8ed43570c3a 100644 --- a/src/ethereum/forks/bpo4/vm/gas.py +++ b/src/ethereum/forks/bpo4/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U64, U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32, taylor_exponential from ..blocks import Header @@ -394,3 +395,6 @@ def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( excess_blob_gas ) + + +apply_spec_repricing("BPO4", globals()) diff --git a/src/ethereum/forks/bpo5/vm/gas.py b/src/ethereum/forks/bpo5/vm/gas.py index 5c30cd6a340..6ee26b7463d 100644 --- a/src/ethereum/forks/bpo5/vm/gas.py +++ b/src/ethereum/forks/bpo5/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U64, U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32, taylor_exponential from ..blocks import Header @@ -394,3 +395,6 @@ def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( excess_blob_gas ) + + +apply_spec_repricing("BPO5", globals()) diff --git a/src/ethereum/forks/byzantium/vm/gas.py b/src/ethereum/forks/byzantium/vm/gas.py index c4aa9846029..fbad7dbfa01 100644 --- a/src/ethereum/forks/byzantium/vm/gas.py +++ b/src/ethereum/forks/byzantium/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -243,3 +244,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("Byzantium", globals()) diff --git a/src/ethereum/forks/cancun/vm/gas.py b/src/ethereum/forks/cancun/vm/gas.py index 0523287c6cf..e6f6db2eab3 100644 --- a/src/ethereum/forks/cancun/vm/gas.py +++ b/src/ethereum/forks/cancun/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U64, U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32, taylor_exponential from ..blocks import Header @@ -370,3 +371,6 @@ def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( excess_blob_gas ) + + +apply_spec_repricing("Cancun", globals()) diff --git a/src/ethereum/forks/constantinople/vm/gas.py b/src/ethereum/forks/constantinople/vm/gas.py index f59699f0347..405c39967ce 100644 --- a/src/ethereum/forks/constantinople/vm/gas.py +++ b/src/ethereum/forks/constantinople/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -244,3 +245,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("Constantinople", globals()) diff --git a/src/ethereum/forks/dao_fork/vm/gas.py b/src/ethereum/forks/dao_fork/vm/gas.py index fc01397ceb0..214d398145d 100644 --- a/src/ethereum/forks/dao_fork/vm/gas.py +++ b/src/ethereum/forks/dao_fork/vm/gas.py @@ -18,6 +18,7 @@ from ethereum.state import Address from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from ..state import State, account_exists @@ -210,3 +211,6 @@ def calculate_message_call_gas( cost = GAS_CALL + gas + create_gas_cost + transfer_gas_cost stipend = gas if value == 0 else GAS_CALL_STIPEND + gas return MessageCallGas(cost, stipend) + + +apply_spec_repricing("DAOFork", globals()) diff --git a/src/ethereum/forks/frontier/vm/gas.py b/src/ethereum/forks/frontier/vm/gas.py index fc01397ceb0..fd1bd3c50e3 100644 --- a/src/ethereum/forks/frontier/vm/gas.py +++ b/src/ethereum/forks/frontier/vm/gas.py @@ -18,6 +18,7 @@ from ethereum.state import Address from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from ..state import State, account_exists @@ -210,3 +211,6 @@ def calculate_message_call_gas( cost = GAS_CALL + gas + create_gas_cost + transfer_gas_cost stipend = gas if value == 0 else GAS_CALL_STIPEND + gas return MessageCallGas(cost, stipend) + + +apply_spec_repricing("Frontier", globals()) diff --git a/src/ethereum/forks/gray_glacier/vm/gas.py b/src/ethereum/forks/gray_glacier/vm/gas.py index 7ac4146d8cf..e576c57b1bb 100644 --- a/src/ethereum/forks/gray_glacier/vm/gas.py +++ b/src/ethereum/forks/gray_glacier/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -243,3 +244,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("GrayGlacier", globals()) diff --git a/src/ethereum/forks/homestead/vm/gas.py b/src/ethereum/forks/homestead/vm/gas.py index fc01397ceb0..c65d22f54c4 100644 --- a/src/ethereum/forks/homestead/vm/gas.py +++ b/src/ethereum/forks/homestead/vm/gas.py @@ -18,6 +18,7 @@ from ethereum.state import Address from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from ..state import State, account_exists @@ -210,3 +211,6 @@ def calculate_message_call_gas( cost = GAS_CALL + gas + create_gas_cost + transfer_gas_cost stipend = gas if value == 0 else GAS_CALL_STIPEND + gas return MessageCallGas(cost, stipend) + + +apply_spec_repricing("Homestead", globals()) diff --git a/src/ethereum/forks/istanbul/vm/gas.py b/src/ethereum/forks/istanbul/vm/gas.py index 8425495cd13..b259f0e04ad 100644 --- a/src/ethereum/forks/istanbul/vm/gas.py +++ b/src/ethereum/forks/istanbul/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -246,3 +247,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("Istanbul", globals()) diff --git a/src/ethereum/forks/london/vm/gas.py b/src/ethereum/forks/london/vm/gas.py index 7ac4146d8cf..2e8d2014d73 100644 --- a/src/ethereum/forks/london/vm/gas.py +++ b/src/ethereum/forks/london/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -243,3 +244,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("London", globals()) diff --git a/src/ethereum/forks/muir_glacier/vm/gas.py b/src/ethereum/forks/muir_glacier/vm/gas.py index 8425495cd13..818236a2b59 100644 --- a/src/ethereum/forks/muir_glacier/vm/gas.py +++ b/src/ethereum/forks/muir_glacier/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -246,3 +247,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("MuirGlacier", globals()) diff --git a/src/ethereum/forks/osaka/vm/gas.py b/src/ethereum/forks/osaka/vm/gas.py index 99ccfb48145..992811fae06 100644 --- a/src/ethereum/forks/osaka/vm/gas.py +++ b/src/ethereum/forks/osaka/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U64, U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32, taylor_exponential from ..blocks import Header @@ -394,3 +395,6 @@ def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( excess_blob_gas ) + + +apply_spec_repricing("Osaka", globals()) diff --git a/src/ethereum/forks/paris/vm/gas.py b/src/ethereum/forks/paris/vm/gas.py index 7ac4146d8cf..bf3af59ca6f 100644 --- a/src/ethereum/forks/paris/vm/gas.py +++ b/src/ethereum/forks/paris/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -243,3 +244,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("Paris", globals()) diff --git a/src/ethereum/forks/prague/vm/gas.py b/src/ethereum/forks/prague/vm/gas.py index 121292173ef..f870a307631 100644 --- a/src/ethereum/forks/prague/vm/gas.py +++ b/src/ethereum/forks/prague/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U64, U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32, taylor_exponential from ..blocks import Header @@ -377,3 +378,6 @@ def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( excess_blob_gas ) + + +apply_spec_repricing("Prague", globals()) diff --git a/src/ethereum/forks/shanghai/vm/gas.py b/src/ethereum/forks/shanghai/vm/gas.py index 44d049d552f..f1936bf4716 100644 --- a/src/ethereum/forks/shanghai/vm/gas.py +++ b/src/ethereum/forks/shanghai/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -264,3 +265,6 @@ def init_code_cost(init_code_length: Uint) -> Uint: """ return GAS_CODE_INIT_PER_WORD * ceil32(init_code_length) // Uint(32) + + +apply_spec_repricing("Shanghai", globals()) diff --git a/src/ethereum/forks/spurious_dragon/vm/gas.py b/src/ethereum/forks/spurious_dragon/vm/gas.py index 937e953a145..2b4f2657cdc 100644 --- a/src/ethereum/forks/spurious_dragon/vm/gas.py +++ b/src/ethereum/forks/spurious_dragon/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -242,3 +243,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("SpuriousDragon", globals()) diff --git a/src/ethereum/forks/tangerine_whistle/vm/gas.py b/src/ethereum/forks/tangerine_whistle/vm/gas.py index 56a9451c117..1257553f6ef 100644 --- a/src/ethereum/forks/tangerine_whistle/vm/gas.py +++ b/src/ethereum/forks/tangerine_whistle/vm/gas.py @@ -17,6 +17,7 @@ from ethereum_types.numeric import U256, Uint from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.gas_repricing import apply_spec_repricing from ethereum.utils.numeric import ceil32 from . import Evm @@ -242,3 +243,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("TangerineWhistle", globals()) diff --git a/src/ethereum/utils/gas_repricing.py b/src/ethereum/utils/gas_repricing.py new file mode 100644 index 00000000000..d929b56434f --- /dev/null +++ b/src/ethereum/utils/gas_repricing.py @@ -0,0 +1,66 @@ +"""Shared gas repricing config loader and spec-side applier.""" + +import json +import os +import warnings +from functools import lru_cache +from pathlib import Path +from typing import Any, Dict, Optional + +_ENV_VAR = "EELS_GAS_REPRICING_CONFIG" + + +@lru_cache(maxsize=1) +def load_repricing_config() -> Optional[Dict[str, Dict[str, Any]]]: + """ + Load gas repricing overrides from JSON config. + + Return None if env var is unset or empty. + """ + config_path = os.environ.get(_ENV_VAR, "") + if not config_path: + return None + + path = Path(config_path) + if not path.is_file(): + raise FileNotFoundError( + f"{_ENV_VAR} points to non-existent file: {config_path}" + ) + + with open(path) as f: + config = json.load(f) + + warnings.warn( + f"Gas repricing config loaded from {config_path}", + stacklevel=2, + ) + return config + + +def apply_spec_repricing( + fork_name: str, + module_globals: dict, +) -> None: + """ + Apply repricing overrides to module globals. + + Mutates module_globals in place, preserving the + original type wrapper (Uint, U64, etc.). + """ + config = load_repricing_config() + if config is None: + return + + overrides = config.get(fork_name) + if overrides is None: + return + + for name, value in overrides.items(): + if name not in module_globals: + raise ValueError( + f"Unknown gas constant '{name}' " + f"in repricing config for fork " + f"'{fork_name}'." + ) + original = module_globals[name] + module_globals[name] = type(original)(value) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index c69736d05e7..2c486986084 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -8,6 +8,10 @@ from ethereum.cancun.blocks import Withdrawal from ethereum_spec_tools.evm_tools.t8n.transition_tool import EELST8N +from execution_testing.cli.eest.commands.gas_map import gas_map +from execution_testing.forks.tests.test_gas_repricing import ( + _clear_repricing_cache, +) from ethereum.ethash import * from ethereum.fork_criteria import Unscheduled @@ -136,6 +140,12 @@ CommentReplaceCommand CommentReplaceCommand.transform_module_impl +# packages/testing/src/execution_testing/cli/eest/commands/gas_map.py +gas_map # Click entry point registered in pyproject.toml + +# packages/testing/src/execution_testing/forks/tests/test_gas_repricing.py +_clear_repricing_cache # pytest autouse fixture + _children # unused attribute (src/ethereum_spec_tools/docc.py:751) # enginex/conftest.py - pytest fixtures (not direct calls)