diff --git a/.gitignore b/.gitignore index b736a90b110..7388d5159c3 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,6 @@ site/ # Temporary data; used for generated checklists tmp/ + +# Gas Repricing +gas_repricing.json diff --git a/Justfile b/Justfile index 350ba08ab61..cf595d93f22 100644 --- a/Justfile +++ b/Justfile @@ -69,7 +69,7 @@ typecheck *args: # Check EELS import isolation [group('static analysis')] lint-spec: - uv run ethereum-spec-lint + EELS_GAS_REPRICING_CONFIG= uv run ethereum-spec-lint # Verify uv.lock is up to date [group('static analysis')] diff --git a/docs/gas_repricing/reference.md b/docs/gas_repricing/reference.md new file mode 100644 index 00000000000..8c169ebb480 --- /dev/null +++ b/docs/gas_repricing/reference.md @@ -0,0 +1,240 @@ +# 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 | +|---|---|---| +| `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 | +| `LOW` | 5 | MUL, DIV, SDIV, MOD, SMOD, CLZ | +| `MID` | 8 | ADDMOD, MULMOD, JUMP | +| `HIGH` | 10 | JUMPI | +| `BASE` | 2 | ADDRESS, ORIGIN, CALLER, CALLVALUE, CALLDATASIZE, CODESIZE, GASPRICE, COINBASE, TIMESTAMP, NUMBER, PREVRANDAO, GASLIMIT, POP, PC, MSIZE, GAS, RETURNDATASIZE, CHAINID, SELFBALANCE, BASEFEE, BLOBBASEFEE | +| `OPCODE_JUMPDEST` | 1 | JUMPDEST | +| `OPCODE_BLOCKHASH` | 20 | BLOCKHASH | + +## Storage Costs + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `WARM_SLOAD` | 100 | SLOAD when slot is warm | +| `COLD_STORAGE_ACCESS` | 2100 | SLOAD when slot is cold | +| `STORAGE_SET` | 20000 | SSTORE: setting a slot from zero to non-zero | +| `STORAGE_RESET` | 2900 | SSTORE: updating an existing non-zero slot or resetting to original value | +| `COLD_STORAGE_WRITE` | 5000 | SSTORE write involving a cold storage slot | + +## Account Access Costs + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `WARM_ACCESS` | 100 | BALANCE, EXTCODESIZE, etc. when warm | +| `COLD_ACCOUNT_ACCESS` | 2600 | BALANCE, EXTCODESIZE, etc. when cold | +| `TX_ACCESS_LIST_ADDRESS` | 2400 | Per address in access list | +| `TX_ACCESS_LIST_STORAGE_KEY` | 1900 | Per storage key in access list | + +## Exponentiation + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `OPCODE_EXP_BASE` | 10 | EXP base cost | +| `OPCODE_EXP_PER_BYTE` | 50 | EXP per byte of exponent | + +## Memory and Copy + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `MEMORY_PER_WORD` | 3 | Memory expansion cost coefficient | +| `OPCODE_COPY_PER_WORD` | 3 | Per-word copy cost (CALLDATACOPY, CODECOPY, etc.) | +| `OPCODE_KECCAK256_BASE` | 30 | SHA3 base cost | +| `OPCODE_KECCACK256_PER_WORD` | 6 | SHA3 per 32-byte word | + +## Logging + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `OPCODE_LOG_BASE` | 375 | LOG base cost | +| `OPCODE_LOG_DATA_PER_BYTE` | 8 | LOG per byte of data | +| `OPCODE_LOG_TOPIC` | 375 | LOG per topic | + +## Transaction Costs + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `TX_BASE` | 21000 | Base transaction cost | +| `TX_CREATE` | 32000 | Additional cost for contract creation tx | +| `TX_DATA_PER_ZERO` | 4 | Per zero byte in tx data | +| `TX_DATA_PER_NON_ZERO` | 16 | Per non-zero byte in tx data | +| `TX_DATA_TOKEN_STANDARD` | 4 | Token cost per data element | +| `TX_DATA_TOKEN_FLOOR` | 0 | Minimum token cost | + +## Call and Create + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `CALL_VALUE` | 9000 | Additional cost when transferring value | +| `CALL_STIPEND` | 2300 | Gas stipend for calls with value | +| `NEW_ACCOUNT` | 25000 | Creating a new account via call | +| `OPCODE_CREATE_BASE` | 32000 | CREATE opcode base cost | +| `CODE_DEPOSIT_PER_BYTE` | 200 | Per byte of deployed code | +| `CODE_INIT_PER_WORD` | 2 | Per word of init code (EIP-3860) | +| `OPCODE_SELFDESTRUCT_BASE` | 5000 | SELFDESTRUCT base cost | + +## Auth (EIP-3074) + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `AUTH_PER_EMPTY_ACCOUNT` | 25000 | AUTH cost for empty account | + +## Precompile Costs + +| GasCosts Field | Typical Value | Precompile | +|---|---|---| +| `PRECOMPILE_ECRECOVER` | 3000 | ecRecover (0x01) | +| `PRECOMPILE_SHA256_BASE` | 60 | SHA-256 base (0x02) | +| `PRECOMPILE_SHA256_PER_WORD` | 12 | SHA-256 per word (0x02) | +| `PRECOMPILE_RIPEMD160_BASE` | 600 | RIPEMD-160 base (0x03) | +| `PRECOMPILE_RIPEMD160_PER_WORD` | 120 | RIPEMD-160 per word (0x03) | +| `PRECOMPILE_IDENTITY_BASE` | 15 | Identity base (0x04) | +| `PRECOMPILE_IDENTITY_PER_WORD` | 3 | Identity per word (0x04) | +| `PRECOMPILE_ECADD` | 150 | BN256 add (0x06) | +| `PRECOMPILE_ECMUL` | 6000 | BN256 mul (0x07) | +| `PRECOMPILE_ECPAIRING_BASE` | 45000 | BN256 pairing base (0x08) | +| `PRECOMPILE_ECPAIRING_PER_POINT` | 34000 | BN256 pairing per point (0x08) | +| `PRECOMPILE_BLAKE2F_BASE` | 0 | BLAKE2 base (0x09) | +| `PRECOMPILE_BLAKE2F_PER_ROUND` | 1 | BLAKE2 per round (0x09) | +| `PRECOMPILE_POINT_EVALUATION` | 50000 | Point evaluation (0x0a) | +| `PRECOMPILE_BLS_G1ADD` | 500 | BLS G1 add (0x0b) | +| `PRECOMPILE_BLS_G1MUL` | 12000 | BLS G1 mul (0x0c) | +| `PRECOMPILE_BLS_G1MAP` | 5500 | BLS G1 map (0x12) | +| `PRECOMPILE_BLS_G2ADD` | 800 | BLS G2 add (0x0d) | +| `PRECOMPILE_BLS_G2MUL` | 45000 | BLS G2 mul (0x0e) | +| `PRECOMPILE_BLS_G2MAP` | 110000 | BLS G2 map (0x13) | +| `PRECOMPILE_BLS_PAIRING_BASE` | 115000 | BLS pairing base (0x11) | +| `PRECOMPILE_BLS_PAIRING_PER_PAIR` | 23000 | BLS pairing per pair (0x11) | +| `PRECOMPILE_P256VERIFY` | 6900 | P256 verify (0x100) | + +## Block Access Lists + +| GasCosts Field | Typical Value | Notes | +|---|---|---| +| `BLOCK_ACCESS_LIST_ITEM` | 2000 | Per-item cost for block access list entries (EIP-7928) | + +## 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 | + +## Opcode-Specific Fields + +Each opcode has a dedicated `OPCODE_` field. By default it equals the +opcode's tier value (see Base Operation Costs), but repricing it changes only +that opcode — so opcodes sharing a tier can be repriced independently. Values +below are typical (Osaka); exact values vary by fork, so `uv run gas-map` is the +authoritative source. + +### Static opcodes + +| GasCosts Field | Typical Value | Opcode | +|---|---|---| +| `OPCODE_ADD` | 3 | ADD | +| `OPCODE_SUB` | 3 | SUB | +| `OPCODE_MUL` | 5 | MUL | +| `OPCODE_DIV` | 5 | DIV | +| `OPCODE_SDIV` | 5 | SDIV | +| `OPCODE_MOD` | 5 | MOD | +| `OPCODE_SMOD` | 5 | SMOD | +| `OPCODE_ADDMOD` | 8 | ADDMOD | +| `OPCODE_MULMOD` | 8 | MULMOD | +| `OPCODE_SIGNEXTEND` | 5 | SIGNEXTEND | +| `OPCODE_LT` | 3 | LT | +| `OPCODE_GT` | 3 | GT | +| `OPCODE_SLT` | 3 | SLT | +| `OPCODE_SGT` | 3 | SGT | +| `OPCODE_EQ` | 3 | EQ | +| `OPCODE_ISZERO` | 3 | ISZERO | +| `OPCODE_AND` | 3 | AND | +| `OPCODE_OR` | 3 | OR | +| `OPCODE_XOR` | 3 | XOR | +| `OPCODE_NOT` | 3 | NOT | +| `OPCODE_BYTE` | 3 | BYTE | +| `OPCODE_SHL` | 3 | SHL | +| `OPCODE_SHR` | 3 | SHR | +| `OPCODE_SAR` | 3 | SAR | +| `OPCODE_CLZ` | 5 | CLZ | +| `OPCODE_CALLDATALOAD` | 3 | CALLDATALOAD | +| `OPCODE_COINBASE` | 2 | COINBASE | +| `OPCODE_BLOCKHASH` | 20 | BLOCKHASH | +| `OPCODE_BLOBHASH` | 3 | BLOBHASH | +| `OPCODE_JUMP` | 8 | JUMP | +| `OPCODE_JUMPI` | 10 | JUMPI | +| `OPCODE_JUMPDEST` | 1 | JUMPDEST | +| `OPCODE_PUSH` | 3 | PUSH1–PUSH32 | +| `OPCODE_DUP` | 3 | DUP1–DUP16 | +| `OPCODE_SWAP` | 3 | SWAP1–SWAP16 | + +### Dynamic opcode base components + +These set the base/per-unit cost of opcodes whose total cost also depends on +runtime context (memory expansion, data length, etc.). + +| GasCosts Field | Typical Value | Opcode / Notes | +|---|---|---| +| `OPCODE_MLOAD_BASE` | 3 | MLOAD base | +| `OPCODE_MSTORE_BASE` | 3 | MSTORE base | +| `OPCODE_MSTORE8_BASE` | 3 | MSTORE8 base | +| `OPCODE_CALLDATACOPY_BASE` | 3 | CALLDATACOPY base | +| `OPCODE_CODECOPY_BASE` | 3 | CODECOPY base | +| `OPCODE_RETURNDATACOPY_BASE` | 3 | RETURNDATACOPY base | +| `OPCODE_MCOPY_BASE` | 3 | MCOPY base | +| `OPCODE_COPY_PER_WORD` | 3 | Per-word cost for copy opcodes | +| `OPCODE_CREATE_BASE` | 32000 | CREATE / CREATE2 base | +| `OPCODE_SELFDESTRUCT_BASE` | 5000 | SELFDESTRUCT base | +| `OPCODE_EXP_BASE` | 10 | EXP base | +| `OPCODE_EXP_PER_BYTE` | 50 | EXP per exponent byte | +| `OPCODE_KECCAK256_BASE` | 30 | KECCAK256 (SHA3) base | +| `OPCODE_KECCACK256_PER_WORD` | 6 | KECCAK256 (SHA3) per word (note: field name is misspelled in source) | +| `OPCODE_LOG_BASE` | 375 | LOG0–LOG4 base | +| `OPCODE_LOG_DATA_PER_BYTE` | 8 | LOG per data byte | +| `OPCODE_LOG_TOPIC` | 375 | LOG per topic | + +## Dynamic Opcodes + +Some opcodes have dynamic gas costs that depend on multiple `GasCosts` fields +and runtime context: + +| Opcode | Relevant GasCosts Fields | Notes | +|---|---|---| +| EXP | `OPCODE_EXP_BASE`, `OPCODE_EXP_PER_BYTE` | Cost depends on exponent size | +| SLOAD | `WARM_SLOAD`, `COLD_STORAGE_ACCESS` | Warm vs cold access | +| SSTORE | `STORAGE_SET`, `STORAGE_RESET`, `WARM_SLOAD`, `COLD_STORAGE_ACCESS` | Complex rules based on original/current/new values | +| SHA3 | `OPCODE_KECCAK256_BASE`, `OPCODE_KECCACK256_PER_WORD` | Base + per-word cost | +| LOG0-LOG4 | `OPCODE_LOG_BASE`, `OPCODE_LOG_DATA_PER_BYTE`, `OPCODE_LOG_TOPIC` | Base + data + topics | +| CALL/CALLCODE | `WARM_ACCESS`, `COLD_ACCOUNT_ACCESS`, `CALL_VALUE`, `NEW_ACCOUNT` | Complex rules based on account state | +| CREATE/CREATE2 | `OPCODE_CREATE_BASE`, `CODE_INIT_PER_WORD` | Base + init code cost | +| BALANCE/EXTCODESIZE | `WARM_ACCESS`, `COLD_ACCOUNT_ACCESS` | Warm vs cold access | +| SELFDESTRUCT | `OPCODE_SELFDESTRUCT_BASE`, `COLD_ACCOUNT_ACCESS`, `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..5a60502734c --- /dev/null +++ b/docs/gas_repricing/repricing_guide.md @@ -0,0 +1,139 @@ +# 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": { + "VERY_LOW": 4, + "COLD_STORAGE_ACCESS": 2200 + }, + "Prague": { + "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 `WARM_SLOAD` and `COLD_STORAGE_ACCESS` are the relevant fields. + +2. Create a repricing config: + + ```json + { + "Osaka": { + "WARM_SLOAD": 150, + "COLD_STORAGE_ACCESS": 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. + +## Regenerating Fixtures Under a New Schedule + +A repricing config changes gas costs, so existing fixtures — which encode the +default schedule — will fail once it is active. That is expected, not a bug. +How you regenerate depends on your intent. + +!!! warning "Protect the canonical fixtures" + - Never run `fill --clean` over the canonical `fixtures/` directory with a + config active: it replaces mainnet-correct fixtures with "what-if" ones. + Always fill experiments into a separate `--output` directory. + - Unset `EELS_GAS_REPRICING_CONFIG` before normal test or fill runs, or + every run silently reprices. + +### What-if comparison (the intended use) + +Fill into a scratch directory with the config active, then diff against a +baseline fill to see how gas usage shifts: + +```bash +# Repriced +EELS_GAS_REPRICING_CONFIG=./reprice.json \ + uv run fill tests/amsterdam/ --fork Amsterdam --output /tmp/fx_repriced --clean + +# Baseline (config unset) +uv run fill tests/amsterdam/ --fork Amsterdam --output /tmp/fx_baseline --clean + +diff -r /tmp/fx_baseline /tmp/fx_repriced +``` + +Differences in `cumulativeGasUsed`, balances, and state/block hashes confirm the +new schedule took effect. Nothing here is committed. + +### Adopting the schedule permanently + +The config is for iteration, not a source of truth. To make a schedule +permanent, bake the values into the spec (`src/ethereum/forks//vm/gas.py`) +and the testing framework (`forks/forks.py`), remove the config, then fill the +canonical `fixtures/` directory without any config set. + +### Quick sanity check + +To confirm the pipeline works under repricing, scope a fill to a few +gas-sensitive tests into a scratch directory: + +```bash +EELS_GAS_REPRICING_CONFIG=./reprice.json uv run fill \ + tests/berlin/eip2929_gas_cost_increases/test_warm_status_revert.py \ + --fork Amsterdam --output /tmp/fx_check --clean +``` diff --git a/docs/navigation.md b/docs/navigation.md index 20bf2f8cd3b..3b0453fd771 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -74,6 +74,9 @@ * [Execute Eth Config](./running_tests/execute/eth_config.md) * [Transaction Metadata](./running_tests/execute/transaction_metadata.md) * [Useful Pytest Options](running_tests/useful_pytest_options.md) + * Gas Repricing + * [Repricing Guide](gas_repricing/repricing_guide.md) + * [GasCosts Reference](gas_repricing/reference.md) * [Developer Doc](dev/index.md) * [Managing Configurations](dev/configurations.md) * [Interactive Library Usage](dev/interactive_usage.md) diff --git a/docs/writing_tests/fork_methods.md b/docs/writing_tests/fork_methods.md index efb4c90d4e3..3af264f77ba 100644 --- a/docs/writing_tests/fork_methods.md +++ b/docs/writing_tests/fork_methods.md @@ -38,7 +38,7 @@ def test_some_feature(fork): ```python def test_transaction_gas(fork, state_test): - gas_cost = fork.gas_costs().GAS_TX_BASE + gas_cost = fork.gas_costs().TX_BASE # Create a transaction with the correct gas parameters for this fork tx = Transaction( diff --git a/packages/testing/pyproject.toml b/packages/testing/pyproject.toml index e6472809571..276e2f56436 100644 --- a/packages/testing/pyproject.toml +++ b/packages/testing/pyproject.toml @@ -95,6 +95,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..2bc3630a1a1 --- /dev/null +++ b/packages/testing/src/execution_testing/cli/eest/commands/gas_map.py @@ -0,0 +1,293 @@ +"""Display the mapping between EVM opcodes and GasCosts field names.""" + +import inspect +import json +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 = ( + "OPCODE_JUMPDEST", + "BASE", + "VERY_LOW", + "LOW", + "MID", + "HIGH", + "OPCODE_BLOCKHASH", + "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("MEMORY_PER_WORD") + 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, + ] + + gas_fields = source_fields.get(name, []) + repricing_fields: list[str] = [] + if callable(cost): + repricing_fields = gas_fields + 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)}") + elif cost in tier_reverse: + repricing_fields = tier_reverse[cost] + lines.append("Type: static") + parts = [f"{fn} ({cost})" for fn in repricing_fields] + lines.append(f"GasCosts: {', '.join(parts)}") + 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." + ) + + if repricing_fields: + snippet = {fork_name: dict.fromkeys(repricing_fields, 0)} + lines.append("") + lines.append("To reprice in gas_repricing.json:") + lines.extend(json.dumps(snippet, indent=2).splitlines()) + + 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 f6d5edb2bf9..288766fd88d 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -450,9 +450,26 @@ def header_slot_number_required(cls) -> bool: # 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/eips/amsterdam/eip_7928.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py index d4b1b9a6e17..dd722691f76 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7928.py @@ -30,12 +30,15 @@ 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: """ The cost per block access list item is introduced in EIP-7928. """ + del block_number, timestamp return replace( - super(EIP7928, cls).gas_costs(), + super(EIP7928, cls)._base_gas_costs(), BLOCK_ACCESS_LIST_ITEM=2000, ) diff --git a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7976.py b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7976.py index b126395a4c7..d387ee27871 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7976.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/amsterdam/eip_7976.py @@ -20,10 +20,13 @@ class EIP7976(BaseFork): """EIP-7976 class.""" @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Transaction data floor token cost is increased from 10 to 16.""" + del block_number, timestamp return replace( - super(EIP7976, cls).gas_costs(), + super(EIP7976, cls)._base_gas_costs(), TX_DATA_TOKEN_FLOOR=16, ) diff --git a/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_196.py b/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_196.py index 343e8ba9947..05e0d73627d 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_196.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_196.py @@ -26,10 +26,13 @@ def precompiles(cls) -> List[Address]: ] + super(EIP196, cls).precompiles() @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Set gas costs for BN254 addition and multiplication.""" + del block_number, timestamp return replace( - super(EIP196, cls).gas_costs(), + super(EIP196, cls)._base_gas_costs(), PRECOMPILE_ECADD=500, PRECOMPILE_ECMUL=40_000, ) diff --git a/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_197.py b/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_197.py index 4d18dc85b3e..f7d348a91fe 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_197.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_197.py @@ -25,10 +25,13 @@ def precompiles(cls) -> List[Address]: ] + super(EIP197, cls).precompiles() @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Set gas costs for BN254 pairing check.""" + del block_number, timestamp return replace( - super(EIP197, cls).gas_costs(), + super(EIP197, cls)._base_gas_costs(), PRECOMPILE_ECPAIRING_BASE=100_000, PRECOMPILE_ECPAIRING_PER_POINT=80_000, ) diff --git a/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_211.py b/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_211.py index d8e053c26ef..c47a3a057a5 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_211.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/byzantium/eip_211.py @@ -26,7 +26,9 @@ def opcode_gas_map( **base_map, Opcodes.RETURNDATASIZE: gas_costs.BASE, Opcodes.RETURNDATACOPY: cls._with_memory_expansion( - cls._with_data_copy(gas_costs.VERY_LOW, gas_costs), + cls._with_data_copy( + gas_costs.OPCODE_RETURNDATACOPY_BASE, gas_costs + ), memory_expansion_calculator, ), } diff --git a/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_4844.py b/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_4844.py index 9774befab96..40cb0f59f66 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_4844.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_4844.py @@ -185,10 +185,13 @@ def engine_new_payload_blob_hashes(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.""" + del block_number, timestamp return replace( - super(EIP4844, cls).gas_costs(), + super(EIP4844, cls)._base_gas_costs(), PRECOMPILE_POINT_EVALUATION=50_000, ) @@ -208,7 +211,7 @@ def opcode_gas_map( base_map = super(EIP4844, cls).opcode_gas_map() # Add Cancun-specific opcodes - return {**base_map, Opcodes.BLOBHASH: gas_costs.VERY_LOW} + return {**base_map, Opcodes.BLOBHASH: gas_costs.OPCODE_BLOBHASH} @classmethod def valid_opcodes(cls) -> List[Opcodes]: diff --git a/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_5656.py b/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_5656.py index 3365f3c2296..0fd801b6095 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_5656.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/cancun/eip_5656.py @@ -27,7 +27,7 @@ def opcode_gas_map( return { **base_map, Opcodes.MCOPY: cls._with_memory_expansion( - cls._with_data_copy(gas_costs.VERY_LOW, gas_costs), + cls._with_data_copy(gas_costs.OPCODE_MCOPY_BASE, gas_costs), memory_expansion_calculator, ), } diff --git a/packages/testing/src/execution_testing/forks/forks/eips/constantinople/eip_145.py b/packages/testing/src/execution_testing/forks/forks/eips/constantinople/eip_145.py index 6a1b1cd854a..0ce8da924a8 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/constantinople/eip_145.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/constantinople/eip_145.py @@ -25,9 +25,9 @@ def opcode_gas_map( base_map = super(EIP145, cls).opcode_gas_map() return { **base_map, - Opcodes.SHL: gas_costs.VERY_LOW, - Opcodes.SHR: gas_costs.VERY_LOW, - Opcodes.SAR: gas_costs.VERY_LOW, + Opcodes.SHL: gas_costs.OPCODE_SHL, + Opcodes.SHR: gas_costs.OPCODE_SHR, + Opcodes.SAR: gas_costs.OPCODE_SAR, } @classmethod diff --git a/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_1108.py b/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_1108.py index 512a2c2c44f..1b56ed499e0 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_1108.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_1108.py @@ -14,10 +14,13 @@ class EIP1108(BaseFork): """EIP-1108 class.""" @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Reduce BN254 precompile gas costs.""" + del block_number, timestamp return replace( - super(EIP1108, cls).gas_costs(), + super(EIP1108, cls)._base_gas_costs(), PRECOMPILE_ECADD=150, PRECOMPILE_ECMUL=6000, PRECOMPILE_ECPAIRING_BASE=45_000, diff --git a/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_152.py b/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_152.py index e8451f88fdf..462c919630f 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_152.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_152.py @@ -24,9 +24,12 @@ def precompiles(cls) -> List[Address]: ] + super(EIP152, cls).precompiles() @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Set BLAKE2F per-round gas cost.""" + del block_number, timestamp return replace( - super(EIP152, cls).gas_costs(), + super(EIP152, cls)._base_gas_costs(), PRECOMPILE_BLAKE2F_PER_ROUND=1, ) diff --git a/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_2028.py b/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_2028.py index ca6fed863ed..d8554136062 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_2028.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/istanbul/eip_2028.py @@ -16,9 +16,12 @@ class EIP2028(BaseFork): """EIP-2028 class.""" @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Reduce non-zero calldata byte gas cost to 16.""" + del block_number, timestamp return replace( - super(EIP2028, cls).gas_costs(), + super(EIP2028, cls)._base_gas_costs(), TX_DATA_PER_NON_ZERO=16, ) diff --git a/packages/testing/src/execution_testing/forks/forks/eips/osaka/eip_7939.py b/packages/testing/src/execution_testing/forks/forks/eips/osaka/eip_7939.py index c53935a4d4d..87c7c704d56 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/osaka/eip_7939.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/osaka/eip_7939.py @@ -25,7 +25,7 @@ def opcode_gas_map( base_map = super(EIP7939, cls).opcode_gas_map() return { **base_map, - Opcodes.CLZ: gas_costs.LOW, + Opcodes.CLZ: gas_costs.OPCODE_CLZ, } @classmethod diff --git a/packages/testing/src/execution_testing/forks/forks/eips/osaka/eip_7951.py b/packages/testing/src/execution_testing/forks/forks/eips/osaka/eip_7951.py index e80674d2725..cafa2175963 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/osaka/eip_7951.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/osaka/eip_7951.py @@ -31,9 +31,12 @@ def precompiles(cls) -> List[Address]: ] + super(EIP7951, cls).precompiles() @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Set the P256VERIFY precompile gas cost.""" + del block_number, timestamp return replace( - super(EIP7951, cls).gas_costs(), + super(EIP7951, cls)._base_gas_costs(), PRECOMPILE_P256VERIFY=6_900, ) diff --git a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_2537.py b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_2537.py index d0cb4edc70b..3dbff023097 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_2537.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_2537.py @@ -43,10 +43,13 @@ def precompiles(cls) -> List[Address]: ] + super(EIP2537, cls).precompiles() @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Add gas costs for BLS12-381 precompiles.""" + del block_number, timestamp return replace( - super(EIP2537, cls).gas_costs(), + super(EIP2537, cls)._base_gas_costs(), PRECOMPILE_BLS_G1ADD=375, PRECOMPILE_BLS_G1MUL=12_000, PRECOMPILE_BLS_G1MAP=5_500, diff --git a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7623.py b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7623.py index d52c5601a16..a8ea38ba831 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7623.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7623.py @@ -25,10 +25,13 @@ class EIP7623(BaseFork): """EIP-7623 class.""" @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Add standard and floor token costs for calldata.""" + del block_number, timestamp return replace( - super(EIP7623, cls).gas_costs(), + super(EIP7623, cls)._base_gas_costs(), TX_DATA_TOKEN_STANDARD=4, TX_DATA_TOKEN_FLOOR=10, ) diff --git a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py index 424c4ab2dab..68b0e5f4bbe 100644 --- a/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py +++ b/packages/testing/src/execution_testing/forks/forks/eips/prague/eip_7702.py @@ -30,10 +30,13 @@ def tx_types(cls) -> List[int]: return [4] + super(EIP7702, cls).tx_types() @classmethod - def gas_costs(cls) -> GasCosts: + def _base_gas_costs( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> GasCosts: """Add gas costs for authorization operations.""" + del block_number, timestamp return replace( - super(EIP7702, cls).gas_costs(), + super(EIP7702, cls)._base_gas_costs(), AUTH_PER_EMPTY_ACCOUNT=25_000, REFUND_AUTH_PER_EXISTING_ACCOUNT=12_500, ) diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 748047b4827..898f1aed1e3 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -94,10 +94,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( # Tiers BASE=BASE, @@ -190,6 +193,13 @@ def gas_costs(cls) -> GasCosts: OPCODE_LOG_TOPIC=375, OPCODE_KECCAK256_BASE=30, OPCODE_KECCACK256_PER_WORD=6, + OPCODE_SHL=VERY_LOW, + OPCODE_SHR=VERY_LOW, + OPCODE_SAR=VERY_LOW, + OPCODE_CLZ=LOW, + OPCODE_BLOBHASH=VERY_LOW, + OPCODE_MCOPY_BASE=VERY_LOW, + OPCODE_RETURNDATACOPY_BASE=VERY_LOW, # Zero-initialized: introduced in later forks, set via # replace() in the fork that activates them. TX_DATA_TOKEN_STANDARD=0, 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..85240740a5d --- /dev/null +++ b/packages/testing/src/execution_testing/forks/tests/test_gas_repricing.py @@ -0,0 +1,341 @@ +"""Tests for gas repricing override mechanism.""" + +import json +import re +from collections.abc import Generator +from dataclasses import fields +from pathlib import Path + +import pytest +from ethereum.utils.gas_repricing import ( + _ENV_VAR, + apply_spec_repricing, + load_repricing_config, +) +from ethereum_types.numeric import U64, Uint + +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": {"TX_BASE": 25000}})) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + config = load_repricing_config() + assert config == {"Osaka": {"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": {"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": {"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": {"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.TX_BASE == 99999 + assert result.COLD_ACCOUNT_ACCESS == base.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": {"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": {"COLD_ACCOUNT_ACCESS": 2100}}) + ) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + with pytest.warns(UserWarning): + costs = Osaka.gas_costs() + assert costs.COLD_ACCOUNT_ACCESS == 2100 + assert costs.TX_BASE == _default_osaka_costs().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": {"TX_BASE": 50000}})) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + with pytest.warns(UserWarning): + costs = PragueToOsakaAtTime15k.gas_costs(timestamp=15000) + assert costs.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": {"TX_BASE": 50000}})) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + with pytest.warns(UserWarning): + costs = PragueToOsakaAtTime15k.gas_costs(timestamp=0) + assert costs.TX_BASE == Prague._base_gas_costs().TX_BASE + + +class _SpecGasCosts: + """Minimal stand-in for a fork's spec-side ``GasCosts`` class.""" + + BASE = Uint(2) + LOW = Uint(5) + BLOB_SCHEDULE_TARGET = U64(6) + + +class TestSpecSideRepricing: + """Tests for apply_spec_repricing (GasCosts class mutation).""" + + @staticmethod + def _make_gas_costs() -> type[_SpecGasCosts]: + # Fresh subclass per call so setattr-based overrides don't leak + # between tests. + class GasCosts(_SpecGasCosts): + pass + + return GasCosts + + def test_no_config(self) -> None: + """Test no-op when env var is unset.""" + gc = self._make_gas_costs() + before = (gc.BASE, gc.LOW, gc.BLOB_SCHEDULE_TARGET) + apply_spec_repricing("TestFork", gc) + assert (gc.BASE, gc.LOW, gc.BLOB_SCHEDULE_TARGET) == before + + 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": {"BASE": 99}})) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + gc = self._make_gas_costs() + before = (gc.BASE, gc.LOW, gc.BLOB_SCHEDULE_TARGET) + with pytest.warns(UserWarning): + apply_spec_repricing("TestFork", gc) + assert (gc.BASE, gc.LOW, gc.BLOB_SCHEDULE_TARGET) == before + + def test_mutates_with_correct_type( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Test that overrides preserve Uint/U64 type wrappers.""" + config_file = tmp_path / "typed.json" + config_file.write_text( + json.dumps( + { + "TestFork": { + "BASE": 99, + "BLOB_SCHEDULE_TARGET": 12, + } + } + ) + ) + monkeypatch.setenv(_ENV_VAR, str(config_file)) + gc = self._make_gas_costs() + with pytest.warns(UserWarning): + apply_spec_repricing("TestFork", gc) + assert gc.BASE == Uint(99) + assert isinstance(gc.BASE, Uint) + assert gc.BLOB_SCHEDULE_TARGET == U64(12) + assert isinstance(gc.BLOB_SCHEDULE_TARGET, U64) + assert gc.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)) + gc = self._make_gas_costs() + with pytest.warns(UserWarning): + with pytest.raises(ValueError, match="NOT_A_CONSTANT"): + apply_spec_repricing("TestFork", gc) + + 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") + gc = self._make_gas_costs() + with pytest.raises(FileNotFoundError): + apply_spec_repricing("TestFork", gc) + + +class TestReferenceDocFreshness: + """Keep docs/gas_repricing/reference.md in sync with GasCosts.""" + + @staticmethod + def _reference_doc() -> Path | None: + for parent in Path(__file__).resolve().parents: + candidate = parent / "docs" / "gas_repricing" / "reference.md" + if candidate.is_file(): + return candidate + return None + + def test_all_gas_costs_fields_documented(self) -> None: + """ + Every GasCosts field must appear in the reference table so the doc + does not drift when fields are added or renamed. + """ + doc = self._reference_doc() + if doc is None: + pytest.skip("reference.md not present in this checkout") + documented = set(re.findall(r"`([A-Z][A-Z0-9_]+)`", doc.read_text())) + missing = sorted( + f.name for f in fields(GasCosts) if f.name not in documented + ) + assert not missing, ( + "GasCosts fields missing from " + "docs/gas_repricing/reference.md: " + f"{missing}. Run `uv run gas-map` to find their values." + ) 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 3dca0cb1d31..4af5674037e 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): @@ -106,6 +106,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 f8ad377ff73..3dc83f02913 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 @@ -527,3 +528,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", GasCosts) diff --git a/src/ethereum/forks/arrow_glacier/vm/gas.py b/src/ethereum/forks/arrow_glacier/vm/gas.py index 2842b80b4d2..c62c62dfa42 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, ulen 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 @@ -340,3 +341,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("ArrowGlacier", GasCosts) diff --git a/src/ethereum/forks/berlin/vm/gas.py b/src/ethereum/forks/berlin/vm/gas.py index 672dc2fd026..1050dff8918 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, ulen 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 @@ -340,3 +341,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("Berlin", GasCosts) diff --git a/src/ethereum/forks/bpo1/vm/gas.py b/src/ethereum/forks/bpo1/vm/gas.py index 2cd628097bc..3f4cb0d60fc 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, ulen 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 @@ -500,3 +501,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", GasCosts) diff --git a/src/ethereum/forks/bpo2/vm/gas.py b/src/ethereum/forks/bpo2/vm/gas.py index f2488b8e20a..e0c57bd3bfd 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, ulen 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 @@ -500,3 +501,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", GasCosts) diff --git a/src/ethereum/forks/bpo3/vm/gas.py b/src/ethereum/forks/bpo3/vm/gas.py index f2488b8e20a..b4fe3d9263a 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, ulen 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 @@ -500,3 +501,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", GasCosts) diff --git a/src/ethereum/forks/bpo4/vm/gas.py b/src/ethereum/forks/bpo4/vm/gas.py index f2488b8e20a..0e5e3c3366f 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, ulen 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 @@ -500,3 +501,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", GasCosts) diff --git a/src/ethereum/forks/bpo5/vm/gas.py b/src/ethereum/forks/bpo5/vm/gas.py index f2488b8e20a..723ef50c122 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, ulen 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 @@ -500,3 +501,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", GasCosts) diff --git a/src/ethereum/forks/byzantium/vm/gas.py b/src/ethereum/forks/byzantium/vm/gas.py index 0395403db01..975bb5b6026 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, ulen 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 @@ -333,3 +334,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("Byzantium", GasCosts) diff --git a/src/ethereum/forks/cancun/vm/gas.py b/src/ethereum/forks/cancun/vm/gas.py index 89ad479051f..8b51646dd5a 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, ulen 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 @@ -471,3 +472,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", GasCosts) diff --git a/src/ethereum/forks/constantinople/vm/gas.py b/src/ethereum/forks/constantinople/vm/gas.py index 770c5212461..3e1d5c8d9b5 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, ulen 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 @@ -337,3 +338,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("Constantinople", GasCosts) diff --git a/src/ethereum/forks/dao_fork/vm/gas.py b/src/ethereum/forks/dao_fork/vm/gas.py index cb663624a36..9c839ec127c 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_tracker import TransactionState, account_exists @@ -298,3 +299,6 @@ def calculate_message_call_gas( ) stipend = gas if value == 0 else GasCosts.CALL_STIPEND + gas return MessageCallGas(cost, stipend) + + +apply_spec_repricing("DAOFork", GasCosts) diff --git a/src/ethereum/forks/frontier/vm/gas.py b/src/ethereum/forks/frontier/vm/gas.py index 1974386b1c3..01d9518f27d 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_tracker import TransactionState, account_exists @@ -296,3 +297,6 @@ def calculate_message_call_gas( ) stipend = gas if value == 0 else GasCosts.CALL_STIPEND + gas return MessageCallGas(cost, stipend) + + +apply_spec_repricing("Frontier", GasCosts) diff --git a/src/ethereum/forks/gray_glacier/vm/gas.py b/src/ethereum/forks/gray_glacier/vm/gas.py index 2842b80b4d2..6b3d5a6ccab 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, ulen 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 @@ -340,3 +341,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("GrayGlacier", GasCosts) diff --git a/src/ethereum/forks/homestead/vm/gas.py b/src/ethereum/forks/homestead/vm/gas.py index cb663624a36..2e8eb727fe6 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_tracker import TransactionState, account_exists @@ -298,3 +299,6 @@ def calculate_message_call_gas( ) stipend = gas if value == 0 else GasCosts.CALL_STIPEND + gas return MessageCallGas(cost, stipend) + + +apply_spec_repricing("Homestead", GasCosts) diff --git a/src/ethereum/forks/istanbul/vm/gas.py b/src/ethereum/forks/istanbul/vm/gas.py index 25ff304ae00..c6a89b4de29 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, ulen 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 @@ -340,3 +341,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("Istanbul", GasCosts) diff --git a/src/ethereum/forks/london/vm/gas.py b/src/ethereum/forks/london/vm/gas.py index 2842b80b4d2..13e3065ddf2 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, ulen 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 @@ -340,3 +341,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("London", GasCosts) diff --git a/src/ethereum/forks/muir_glacier/vm/gas.py b/src/ethereum/forks/muir_glacier/vm/gas.py index 25ff304ae00..590d835357b 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, ulen 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 @@ -340,3 +341,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("MuirGlacier", GasCosts) diff --git a/src/ethereum/forks/osaka/vm/gas.py b/src/ethereum/forks/osaka/vm/gas.py index 1d49b46098c..e744ef20043 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, ulen 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 @@ -500,3 +501,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", GasCosts) diff --git a/src/ethereum/forks/paris/vm/gas.py b/src/ethereum/forks/paris/vm/gas.py index b4b9ab1e402..5353d887b86 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, ulen 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 @@ -340,3 +341,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("Paris", GasCosts) diff --git a/src/ethereum/forks/prague/vm/gas.py b/src/ethereum/forks/prague/vm/gas.py index e6cc23df1b8..d805c795a34 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, ulen 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 @@ -480,3 +481,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", GasCosts) diff --git a/src/ethereum/forks/shanghai/vm/gas.py b/src/ethereum/forks/shanghai/vm/gas.py index f7b597f700f..f9071246a7a 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, ulen 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 @@ -362,3 +363,6 @@ def init_code_cost(init_code_length: Uint) -> Uint: """ return GasCosts.CODE_INIT_PER_WORD * ceil32(init_code_length) // Uint(32) + + +apply_spec_repricing("Shanghai", GasCosts) diff --git a/src/ethereum/forks/spurious_dragon/vm/gas.py b/src/ethereum/forks/spurious_dragon/vm/gas.py index c4cd3ce528f..c79a0449292 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, ulen 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 @@ -326,3 +327,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("SpuriousDragon", GasCosts) diff --git a/src/ethereum/forks/tangerine_whistle/vm/gas.py b/src/ethereum/forks/tangerine_whistle/vm/gas.py index 93c81fae94d..e91899f4e1e 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, ulen 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 @@ -326,3 +327,6 @@ def max_message_call_gas(gas: Uint) -> Uint: """ return gas - (gas // Uint(64)) + + +apply_spec_repricing("TangerineWhistle", GasCosts) diff --git a/src/ethereum/utils/gas_repricing.py b/src/ethereum/utils/gas_repricing.py new file mode 100644 index 00000000000..9dfd3a8d21e --- /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, + gas_costs: type, +) -> None: + """ + Apply repricing overrides to a fork's ``GasCosts`` class. + + Mutate the class attributes 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 not hasattr(gas_costs, name): + raise ValueError( + f"Unknown gas constant '{name}' " + f"in repricing config for fork " + f"'{fork_name}'." + ) + original = getattr(gas_costs, name) + setattr(gas_costs, name, type(original)(value)) diff --git a/src/ethereum_spec_tools/lint/lints/glacier_forks_hygiene.py b/src/ethereum_spec_tools/lint/lints/glacier_forks_hygiene.py index 55d58c6a264..3ad5f9df089 100644 --- a/src/ethereum_spec_tools/lint/lints/glacier_forks_hygiene.py +++ b/src/ethereum_spec_tools/lint/lints/glacier_forks_hygiene.py @@ -231,6 +231,15 @@ def visit_Expr(self, expr: ast.Expr) -> None: ): return + # Ignore the module-level apply_spec_repricing() hook appended to + # each fork's gas.py; it is identical machinery across forks. + if ( + isinstance(expr.value, ast.Call) + and isinstance(expr.value.func, ast.Name) + and expr.value.func.id == "apply_spec_repricing" + ): + return + print(f"The expression {type(expr)} has been ignored.") def visit_AsyncFunctionDef(self, function: ast.AsyncFunctionDef) -> None: diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 6b045fb9e8b..5b55f4d0cb9 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 @@ -155,6 +159,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) # evm_tools/loaders/fixture_loader.py - abstract methods