diff --git a/docs/writing_tests/index.md b/docs/writing_tests/index.md index 8f446b47cd..0d3ebee67b 100644 --- a/docs/writing_tests/index.md +++ b/docs/writing_tests/index.md @@ -28,4 +28,8 @@ For help deciding which test format to select, see [Types of Tests](./types_of_t - [Gas Optimization](./gas_optimization.md) - Optimize gas limits in your tests for efficiency and compatibility with future forks. - [Porting tests](./porting_legacy_tests.md): A guide to porting @ethereum/tests to EEST. +## Advanced Topics + +- [Opcode Metadata and Gas Calculations](./opcode_metadata.md) - Calculate gas costs and refunds using opcode metadata (advanced feature for gas-focused tests) + Please check that your code adheres to the repo's coding standards and read the other pages in this section for more background and an explanation of how to implement state transition and blockchain tests. diff --git a/docs/writing_tests/opcode_metadata.md b/docs/writing_tests/opcode_metadata.md new file mode 100644 index 0000000000..e6530e4267 --- /dev/null +++ b/docs/writing_tests/opcode_metadata.md @@ -0,0 +1,406 @@ +# Advanced: Opcode Metadata and Gas Calculations + +## Overview + +The execution testing package provides capabilities to calculate gas costs and refunds for individual opcodes and bytecode sequences based on their metadata. This is useful for: + +- Writing tests that rely on exact gas consumption +- Creating gas benchmarking tests +- Validating gas cost calculations for specific opcode scenarios +- Future-proofing tests against breaking in upcoming forks that change gas rules + +## Opcode Metadata + +Many opcodes accept metadata parameters that affect their gas cost calculations. Metadata represents runtime state information that influences gas consumption. + +### Common Metadata Fields + +#### Memory Expansion + +Opcodes that can expand memory accept: + +- `new_memory_size`: Memory size after the operation (in bytes) +- `old_memory_size`: Memory size before the operation (in bytes) + +Example: + +```python +Op.MSTORE(offset=0, value=0x123, new_memory_size=32, old_memory_size=0) +``` + +#### Account Access (Warm/Cold) + +Opcodes that access accounts accept: + +- `address_warm`: Whether the address is already warm (`True`) or cold (`False`) + +Example: + +```python +Op.BALANCE(address=0x1234, address_warm=True) # Warm access: 100 gas +Op.BALANCE(address=0x1234, address_warm=False) # Cold access: 2,600 gas +``` + +#### Storage Access + +- `key_warm`: Whether the storage key is already warm +- `original_value`: The value the storage key had at the beginning of the transaction +- `current_value`: The value the storage key holds at the time the opcode is executed +- `new_value`: The value set by the opcode + +Example: + +```python +Op.SSTORE(key=1, value=0, key_warm=True, original_value=1, new_value=0) +``` + +#### Data Copy Operations + +- `data_size`: Number of bytes being copied + +Example: + +```python +Op.CALLDATACOPY(dest_offset=0, offset=0, size=64, data_size=64, new_memory_size=64) +``` + +#### Contract Creation + +- `init_code_size`: Size of the initialization code (affects CREATE/CREATE2 gas) + +Example: + +```python +Op.CREATE(value=0, offset=0, size=100, init_code_size=100, new_memory_size=100) +``` + +#### Call Operations + +- `address_warm`: Whether the call target is warm +- `value_transfer`: Whether value is being transferred +- `account_new`: Whether creating a new account + +Example: + +```python +Op.CALL( + gas=100000, + address=0x5678, + value=1, + address_warm=False, + value_transfer=True, + account_new=True, + new_memory_size=64 +) +``` + +#### Return from Contract Creation + +- `code_deposit_size`: Size of bytecode being deployed (only for RETURN in initcode) + +Example: + +```python +Op.RETURN(offset=0, size=100, code_deposit_size=100, new_memory_size=100) +``` + +#### Exponential Operation + +- `exponent`: The exponent value (byte size calculated automatically) + +Example: + +```python +Op.EXP(a=2, exponent=0xFFFFFF) # Gas based on exponent byte size +``` + +## Calculating Gas Costs + +### For Individual Opcodes + +Use the fork's `opcode_gas_calculator()` to get gas costs: + +```python +from execution_testing import Op +from execution_testing.forks import Osaka + +# Get the gas calculator for the fork +gas_calc = Osaka.opcode_gas_calculator() + +# Calculate gas for a simple opcode +add_gas = gas_calc(Op.ADD) # Returns 3 (G_VERY_LOW) + +# Calculate gas for an opcode with metadata +mstore_gas = gas_calc(Op.MSTORE(new_memory_size=32)) +# Returns: 3 (base) + memory_expansion_cost(32 bytes) + +# Calculate gas for complex metadata +call_gas = gas_calc( + Op.CALL( + address_warm=False, + value_transfer=True, + account_new=True, + new_memory_size=64 + ) +) +# Returns: 2,600 (cold) + 9,000 (value) + 25,000 (new account) + memory_expansion_cost +``` + +### For Bytecode Sequences + +Use the `bytecode.gas_cost(fork)` method: + +```python +from execution_testing import Op +from execution_testing.forks import Osaka + +# Simple bytecode +bytecode = Op.PUSH1(1) + Op.PUSH1(2) + Op.ADD +total_gas = bytecode.gas_cost(Osaka) +# Returns: 3 + 3 + 3 = 9 + +# With metadata +bytecode = Op.MSTORE(0, 1, new_memory_size=32) + Op.MLOAD(0) # Last opcode does not expand the memory further +total_gas = bytecode.gas_cost(Osaka) +# Calculates total including memory expansion +``` + +### Fork-Specific Gas Costs + +Gas costs can vary between forks. Always specify the fork when calculating: + +```python +from execution_testing.forks import Shanghai, Osaka, Paris + +# CREATE gas costs differ between forks (EIP-3860 in Shanghai) +create_op = Op.CREATE(init_code_size=100, new_memory_size=100) + +shanghai_gas = create_op.gas_cost(Shanghai) +# Returns: 32,000 + (2 * 4 words) + memory_expansion = 32,008 + expansion + +osaka_gas = create_op.gas_cost(Osaka) +# Same calculation, inherited from Shanghai + +assert shanghai_gas == osaka_gas + +paris_gas = create_op.gas_cost(Paris) +# Different calculation, prior to Shanghai the initcode was not metered + +assert paris_gas != shanghai_gas +``` + +## Calculating Refunds + +Some opcodes provide gas refunds. Currently, only `SSTORE` provides refunds when clearing storage. + +### For Individual Opcodes + +```python +from execution_testing import Op +from execution_testing.forks import Osaka + +# Get the refund calculator +refund_calc = Osaka.opcode_refund_calculator() + +# SSTORE clearing storage (non-zero → zero) +sstore_refund = refund_calc( + Op.SSTORE(new_value=0, original_value=1) +) +# Returns: 4,800 (R_STORAGE_CLEAR) + +# SSTORE not clearing storage +no_refund = refund_calc( + Op.SSTORE(new_value=2, original_value=1) +) +# Returns: 0 + +# Other opcodes don't provide refunds +add_refund = refund_calc(Op.ADD) +# Returns: 0 +``` + +### For Bytecode Sequences + +Use the `bytecode.refund(fork)` method: + +```python +from execution_testing import Op +from execution_testing.forks import Osaka + +# Multiple SSTORE operations clearing storage +bytecode = ( + Op.SSTORE(0, 0, original_value=1, new_value=0) + + Op.SSTORE(1, 0, original_value=1, new_value=0) +) +total_refund = bytecode.refund(Osaka) +# Returns: 4,800 + 4,800 = 9,600 +``` + +## Writing Tests with Gas Calculations + +### Example: Out-of-Gas Test Using Exact Gas Calculation + +This example demonstrates a practical use case: testing that a subcall with insufficient gas fails correctly. + +```python +import pytest +from execution_testing import ( + Account, + Alloc, + Environment, + Fork, + StateTestFiller, + Transaction, + Op, +) + +@pytest.mark.valid_from("Byzantium") +def test_subcall_out_of_gas( + state_test: StateTestFiller, + fork: Fork, + pre: Alloc, + env: Environment, +): + """ + Test that a subcall with exactly (gas_needed - 1) fails with out-of-gas, + and verify via SSTORE that the operation didn't execute. + """ + + # Define the code that will run in the subcall + # A simple SSTORE operation with known gas cost + subcall_code = Op.SSTORE( + slot=0, + value=1, + key_warm=False, # Cold storage access + new_value=1, + ) + Op.STOP + + # Calculate exact gas needed for this operation in this fork + subcall_gas_needed = subcall_code.gas_cost(fork) + + # Deploy contract that will be called + callee = pre.deploy_contract(subcall_code) + + # Deploy caller contract that calls with insufficient gas + caller = pre.deploy_contract( + # Call with exactly 1 gas less than needed + Op.SSTORE( + slot=0, + value=Op.CALL( + gas=subcall_gas_needed - 1, # Insufficient gas! + address=callee, + value=0, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ), + ) + ) + + tx = Transaction( + to=caller, + gas_limit=500_000, + sender=pre.fund_eoa(), + ) + + post = { + caller: Account( + storage={ + 0: 0, # CALL returns 0 on failure + }, + ), + callee: Account( + storage={ + 0: 0, # SSTORE didn't execute due to OOG + }, + ), + } + + state_test(env=env, pre=pre, post=post, tx=tx) +``` + +This example shows: + +- **Practical Use**: Testing out-of-gas conditions requires knowing exact gas costs +- **Metadata Usage**: Using SSTORE metadata to calculate precise gas requirements +- **Verification**: Post-state checks confirm the subcall failed (storage unchanged) +- **Future-Proof**: Uses `gas_cost(fork)` so it adapts if gas calculations change + +## Important Considerations + +### 1. Most Tests Don't Need This + +Most tests simply need to specify sufficient gas for the transaction to work and do not need to be exact. You typically only need explicit gas calculations when: + +- Writing gas-focused benchmarks +- Verifying exact gas consumption for specific scenarios +- Testing edge cases in gas metering (off-by-one checks) + +### 2. Metadata Must Match Runtime State + +The metadata is not checked against the executed bytecode! When using metadata in tests, ensure the pre-state and transactions are accurately set up to reflect the bytecode metadata: + +```python +# ❌ Incorrect: This is impossible because the first `Op.BALANCE` will always warm up the account: +Op.BALANCE(address=some_address, address_warm=False) + Op.BALANCE(address=some_address, address_warm=False) + +# ✅ Correct: If the address was accessed earlier, it's warm: +Op.BALANCE(address=some_address, address_warm=False) + Op.BALANCE(address=some_address, address_warm=True) +``` + +Example using the test pre-conditions: + +```python +# ✅ Correct: The address is in the access list, it's warm from the beginning: +code_address = pre.deploy_contract(Op.BALANCE(address=some_address, address_warm=True) + Op.BALANCE(address=some_address, address_warm=True)) +... +tx = Transaction( + to=code_address, + gas_limit=500_000, + sender=pre.fund_eoa(), + access_list=[AccessList(address=code_address, storage_keys=[])] +) +``` + +### 3. Memory Size Calculations + +Memory expansion is calculated from the highest offset accessed: + +```python +# MSTORE to offset 0 requires 32 bytes of memory +Op.MSTORE(offset=0, value=0x123, new_memory_size=32) + +# MSTORE to offset 32 requires 64 bytes total +Op.MSTORE(offset=32, value=0x456, new_memory_size=64, old_memory_size=32) +``` + +### 4. Fork Activation Matters + +Some opcodes are only available in certain forks: + +```python +# ✅ Available in Shanghai and later +Op.PUSH0.gas_cost(Shanghai) + +# ❌ Not available in Paris +# Op.PUSH0.gas_cost(Paris) # Would raise an error + +# ✅ Available in Osaka and later +Op.CLZ.gas_cost(Osaka) +``` + +### 5. Refunds Are Limited + +Only certain operations provide refunds: + +- **SSTORE**: Refund when clearing storage (non-zero → zero) +- Most opcodes return 0 refund + +Transaction-level operations like authorization lists also provide refunds, but these are handled at the transaction level, not in opcode metadata. + +## See Also + +- [Gas Optimization](./gas_optimization.md) - Optimizing test gas limits +- [Fork Methods](./fork_methods.md) - Using fork-specific methods +- [Writing Tests](./writing_a_new_test.md) - General test writing guide diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py index ed04021ded..7aa4d0cd68 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/forks/tests/test_forks.py @@ -38,7 +38,11 @@ def test_all_forks({StateTest.pytest_parameter_name()}): ) result = pytester.runpytest("-c", "pytest-fill.ini", "-v") all_forks = get_deployed_forks() - forks_under_test = forks_from_until(all_forks[0], all_forks[-1]) + forks_under_test = [ + f + for f in forks_from_until(all_forks[0], all_forks[-1]) + if not f.ignore() + ] expected_skipped = 2 # eels doesn't support Constantinople expected_passed = ( len(forks_under_test) * len(StateTest.supported_fixture_formats) diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 4e20a0e2bb..a061497b25 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -1,8 +1,9 @@ """Abstract base class for Ethereum forks.""" -from abc import ABC, ABCMeta, abstractmethod +from abc import ABCMeta, abstractmethod from typing import ( Any, + Callable, ClassVar, Dict, List, @@ -23,7 +24,12 @@ BlobSchedule, ) from execution_testing.base_types.conversions import BytesConvertible -from execution_testing.vm import EVMCodeType, Opcodes +from execution_testing.vm import ( + EVMCodeType, + ForkOpcodeInterface, + OpcodeBase, + Opcodes, +) from .base_decorators import prefer_transition_to_method from .gas_costs import GasCosts @@ -237,7 +243,7 @@ def __le__(cls, other: "BaseForkMeta") -> bool: return cls is other or BaseForkMeta._is_subclass_of(other, cls) -class BaseFork(ABC, metaclass=BaseForkMeta): +class BaseFork(ForkOpcodeInterface, metaclass=BaseForkMeta): """ An abstract class representing an Ethereum fork. @@ -355,6 +361,21 @@ def gas_costs( """Return dataclass with the gas costs constants for the fork.""" pass + @classmethod + @abstractmethod + def opcode_gas_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """ + Return a mapping of opcodes to either int or callable. + + The values of the mapping can be as follow: + - Constants (int): Direct gas cost values from gas_costs() + - Callables: Functions that take the opcode instance with metadata and + return gas cost + """ + pass + @classmethod @abstractmethod def memory_expansion_gas_calculator( diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 7e650a821d..62c108c65f 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -4,7 +4,16 @@ from hashlib import sha256 from os.path import realpath from pathlib import Path -from typing import List, Literal, Mapping, Optional, Sized, Tuple +from typing import ( + Callable, + Dict, + List, + Literal, + Mapping, + Optional, + Sized, + Tuple, +) from execution_testing.base_types import ( AccessList, @@ -14,7 +23,12 @@ ForkBlobSchedule, ) from execution_testing.base_types.conversions import BytesConvertible -from execution_testing.vm import EVMCodeType, Opcodes +from execution_testing.vm import ( + EVMCodeType, + OpcodeBase, + OpcodeGasCalculator, + Opcodes, +) from ..base_fork import ( BaseFeeChangeCalculator, @@ -128,6 +142,7 @@ def gas_costs( G_WARM_SLOAD=100, G_COLD_SLOAD=2_100, G_STORAGE_SET=20_000, + G_STORAGE_UPDATE=5_000, G_STORAGE_RESET=2_900, R_STORAGE_CLEAR=4_800, G_SELF_DESTRUCT=5_000, @@ -157,6 +172,494 @@ def gas_costs( R_AUTHORIZATION_EXISTING_AUTHORITY=0, ) + @classmethod + def _with_memory_expansion( + cls, + base_gas: int | Callable[[OpcodeBase], int], + memory_expansion_gas_calculator: MemoryExpansionGasCalculator, + ) -> Callable[[OpcodeBase], int]: + """ + Wrap a gas cost calculator to include memory expansion cost. + + Args: + base_gas: Either a constant gas cost (int) or a callable that + calculates it + memory_expansion_gas_calculator: Calculator for memory expansion + cost + + Returns: + A callable that calculates base_gas + memory_expansion_cost + + """ + + def wrapper(opcode: OpcodeBase) -> int: + # Calculate base gas cost + if callable(base_gas): + base_cost = base_gas(opcode) + else: + base_cost = base_gas + + # Add memory expansion cost if metadata is present + new_memory_size = opcode.metadata["new_memory_size"] + old_memory_size = opcode.metadata["old_memory_size"] + expansion_cost = memory_expansion_gas_calculator( + new_bytes=new_memory_size, previous_bytes=old_memory_size + ) + + return base_cost + expansion_cost + + return wrapper + + @classmethod + def _with_account_access( + cls, + base_gas: int | Callable[[OpcodeBase], int], + gas_costs: "GasCosts", + ) -> Callable[[OpcodeBase], int]: + """ + Wrap a gas cost calculator to include account access cost. + + Args: + base_gas: Either a constant gas cost (int) or a callable that + calculates it + gas_costs: The gas costs dataclass for accessing warm/cold costs + + Returns: + A callable that calculates base_gas + account_access_cost + + """ + + def wrapper(opcode: OpcodeBase) -> int: + # Calculate base gas cost + if callable(base_gas): + base_cost = base_gas(opcode) + else: + base_cost = base_gas + + # Add account access cost based on warmth + if opcode.metadata["address_warm"]: + access_cost = gas_costs.G_WARM_ACCOUNT_ACCESS + else: + access_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + + return base_cost + access_cost + + return wrapper + + @classmethod + def _with_data_copy( + cls, + base_gas: int | Callable[[OpcodeBase], int], + gas_costs: "GasCosts", + ) -> Callable[[OpcodeBase], int]: + """ + Wrap a gas cost calculator to include data copy cost. + + Args: + base_gas: Either a constant gas cost (int) or a callable that + calculates it + gas_costs: The gas costs dataclass for accessing G_COPY + + Returns: + A callable that calculates base_gas + copy_cost + + """ + + def wrapper(opcode: OpcodeBase) -> int: + # Calculate base gas cost + if callable(base_gas): + base_cost = base_gas(opcode) + else: + base_cost = base_gas + + # Add copy cost based on data size + data_size = opcode.metadata["data_size"] + word_count = (data_size + 31) // 32 + copy_cost = gas_costs.G_COPY * word_count + + return base_cost + copy_cost + + return wrapper + + @classmethod + def opcode_gas_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """ + Return a mapping of opcodes to their gas costs. + + Each entry is either: + - Constants (int): Direct gas cost values from gas_costs() + - Callables: Functions that take the opcode instance with metadata and + return gas cost + """ + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + memory_expansion_calculator = cls.memory_expansion_gas_calculator( + block_number=block_number, timestamp=timestamp + ) + + # Define the opcode gas cost mapping + # Each entry is either: + # - an int (constant cost) + # - a callable(opcode) -> int + return { + # Stop and arithmetic operations + Opcodes.STOP: 0, + Opcodes.ADD: gas_costs.G_VERY_LOW, + Opcodes.MUL: gas_costs.G_LOW, + Opcodes.SUB: gas_costs.G_VERY_LOW, + Opcodes.DIV: gas_costs.G_LOW, + Opcodes.SDIV: gas_costs.G_LOW, + Opcodes.MOD: gas_costs.G_LOW, + Opcodes.SMOD: gas_costs.G_LOW, + Opcodes.ADDMOD: gas_costs.G_MID, + Opcodes.MULMOD: gas_costs.G_MID, + Opcodes.EXP: lambda op: gas_costs.G_EXP + + gas_costs.G_EXP_BYTE + * ((op.metadata["exponent"].bit_length() + 7) // 8), + Opcodes.SIGNEXTEND: gas_costs.G_LOW, + # Comparison & bitwise logic operations + Opcodes.LT: gas_costs.G_VERY_LOW, + Opcodes.GT: gas_costs.G_VERY_LOW, + Opcodes.SLT: gas_costs.G_VERY_LOW, + Opcodes.SGT: gas_costs.G_VERY_LOW, + Opcodes.EQ: gas_costs.G_VERY_LOW, + Opcodes.ISZERO: gas_costs.G_VERY_LOW, + Opcodes.AND: gas_costs.G_VERY_LOW, + Opcodes.OR: gas_costs.G_VERY_LOW, + Opcodes.XOR: gas_costs.G_VERY_LOW, + Opcodes.NOT: gas_costs.G_VERY_LOW, + Opcodes.BYTE: gas_costs.G_VERY_LOW, + # SHA3 + Opcodes.SHA3: cls._with_memory_expansion( + lambda op: gas_costs.G_KECCAK_256 + + gas_costs.G_KECCAK_256_WORD + * ((op.metadata["data_size"] + 31) // 32), + memory_expansion_calculator, + ), + # Environmental information + Opcodes.ADDRESS: gas_costs.G_BASE, + Opcodes.BALANCE: cls._with_account_access(0, gas_costs), + Opcodes.ORIGIN: gas_costs.G_BASE, + Opcodes.CALLER: gas_costs.G_BASE, + Opcodes.CALLVALUE: gas_costs.G_BASE, + Opcodes.CALLDATALOAD: gas_costs.G_VERY_LOW, + Opcodes.CALLDATASIZE: gas_costs.G_BASE, + Opcodes.CALLDATACOPY: cls._with_memory_expansion( + cls._with_data_copy(gas_costs.G_VERY_LOW, gas_costs), + memory_expansion_calculator, + ), + Opcodes.CODESIZE: gas_costs.G_BASE, + Opcodes.CODECOPY: cls._with_memory_expansion( + cls._with_data_copy(gas_costs.G_VERY_LOW, gas_costs), + memory_expansion_calculator, + ), + Opcodes.GASPRICE: gas_costs.G_BASE, + Opcodes.EXTCODESIZE: cls._with_account_access(0, gas_costs), + Opcodes.EXTCODECOPY: cls._with_memory_expansion( + cls._with_data_copy( + cls._with_account_access(0, gas_costs), + gas_costs, + ), + memory_expansion_calculator, + ), + # Block information + Opcodes.BLOCKHASH: gas_costs.G_BLOCKHASH, + Opcodes.COINBASE: gas_costs.G_BASE, + Opcodes.TIMESTAMP: gas_costs.G_BASE, + Opcodes.NUMBER: gas_costs.G_BASE, + Opcodes.PREVRANDAO: gas_costs.G_BASE, + Opcodes.GASLIMIT: gas_costs.G_BASE, + # Stack, memory, storage and flow operations + Opcodes.POP: gas_costs.G_BASE, + Opcodes.MLOAD: cls._with_memory_expansion( + gas_costs.G_VERY_LOW, memory_expansion_calculator + ), + Opcodes.MSTORE: cls._with_memory_expansion( + gas_costs.G_VERY_LOW, memory_expansion_calculator + ), + Opcodes.MSTORE8: cls._with_memory_expansion( + gas_costs.G_VERY_LOW, memory_expansion_calculator + ), + Opcodes.SLOAD: lambda op: gas_costs.G_WARM_SLOAD + if op.metadata["key_warm"] + else gas_costs.G_COLD_SLOAD, + Opcodes.SSTORE: lambda op: cls._calculate_sstore_gas( + op, gas_costs + ), + Opcodes.JUMP: gas_costs.G_MID, + Opcodes.JUMPI: gas_costs.G_HIGH, + Opcodes.PC: gas_costs.G_BASE, + Opcodes.MSIZE: gas_costs.G_BASE, + Opcodes.GAS: gas_costs.G_BASE, + Opcodes.JUMPDEST: gas_costs.G_JUMPDEST, + # Push operations (PUSH1 through PUSH32) + **{ + getattr(Opcodes, f"PUSH{i}"): gas_costs.G_VERY_LOW + for i in range(1, 33) + }, + # Dup operations (DUP1 through DUP16) + **{ + getattr(Opcodes, f"DUP{i}"): gas_costs.G_VERY_LOW + for i in range(1, 17) + }, + # Swap operations (SWAP1 through SWAP16) + **{ + getattr(Opcodes, f"SWAP{i}"): gas_costs.G_VERY_LOW + for i in range(1, 17) + }, + # Logging operations + Opcodes.LOG0: cls._with_memory_expansion( + lambda op: gas_costs.G_LOG + + gas_costs.G_LOG_DATA * op.metadata["data_size"], + memory_expansion_calculator, + ), + Opcodes.LOG1: cls._with_memory_expansion( + lambda op: gas_costs.G_LOG + + gas_costs.G_LOG_DATA * op.metadata["data_size"] + + gas_costs.G_LOG_TOPIC, + memory_expansion_calculator, + ), + Opcodes.LOG2: cls._with_memory_expansion( + lambda op: gas_costs.G_LOG + + gas_costs.G_LOG_DATA * op.metadata["data_size"] + + gas_costs.G_LOG_TOPIC * 2, + memory_expansion_calculator, + ), + Opcodes.LOG3: cls._with_memory_expansion( + lambda op: gas_costs.G_LOG + + gas_costs.G_LOG_DATA * op.metadata["data_size"] + + gas_costs.G_LOG_TOPIC * 3, + memory_expansion_calculator, + ), + Opcodes.LOG4: cls._with_memory_expansion( + lambda op: gas_costs.G_LOG + + gas_costs.G_LOG_DATA * op.metadata["data_size"] + + gas_costs.G_LOG_TOPIC * 4, + memory_expansion_calculator, + ), + # System operations + Opcodes.CREATE: cls._with_memory_expansion( + lambda op: cls._calculate_create_gas(op, gas_costs), + memory_expansion_calculator, + ), + Opcodes.CALL: cls._with_memory_expansion( + lambda op: cls._calculate_call_gas(op, gas_costs), + memory_expansion_calculator, + ), + Opcodes.CALLCODE: cls._with_memory_expansion( + lambda op: cls._calculate_call_gas(op, gas_costs), + memory_expansion_calculator, + ), + Opcodes.RETURN: cls._with_memory_expansion( + lambda op: cls._calculate_return_gas(op, gas_costs), + memory_expansion_calculator, + ), + Opcodes.INVALID: 0, + Opcodes.SELFDESTRUCT: lambda op: cls._calculate_selfdestruct_gas( + op, gas_costs + ), + } + + @classmethod + def opcode_gas_calculator( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> OpcodeGasCalculator: + """ + Return callable that calculates the gas cost of a single opcode. + """ + opcode_gas_map = cls.opcode_gas_map( + block_number=block_number, timestamp=timestamp + ) + + def fn(opcode: OpcodeBase) -> int: + # Get the gas cost or calculator + if opcode not in opcode_gas_map: + raise ValueError( + f"No gas cost defined for opcode: {opcode._name_}" + ) + gas_cost_or_calculator = opcode_gas_map[opcode] + + # If it's a callable, call it with the opcode + if callable(gas_cost_or_calculator): + return gas_cost_or_calculator(opcode) + + # Otherwise it's a constant + return gas_cost_or_calculator + + return fn + + @classmethod + def opcode_refund_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """ + Return a mapping of opcodes to their gas refunds. + + Each entry is either: + - Constants (int): Direct gas refund values + - Callables: Functions that take the opcode instance with metadata and + return gas refund + """ + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + + # Only SSTORE provides refunds + return { + Opcodes.SSTORE: lambda op: cls._calculate_sstore_refund( + op, gas_costs + ), + } + + @classmethod + def opcode_refund_calculator( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> OpcodeGasCalculator: + """ + Return callable that calculates the gas refund of a single opcode. + """ + opcode_refund_map = cls.opcode_refund_map( + block_number=block_number, timestamp=timestamp + ) + + def fn(opcode: OpcodeBase) -> int: + # Get the gas refund or calculator + if opcode not in opcode_refund_map: + # Most opcodes don't provide refunds + return 0 + refund_or_calculator = opcode_refund_map[opcode] + + # If it's a callable, call it with the opcode + if callable(refund_or_calculator): + return refund_or_calculator(opcode) + + # Otherwise it's a constant + return refund_or_calculator + + return fn + + @classmethod + def _calculate_sstore_refund( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Calculate SSTORE gas refund based on metadata.""" + metadata = opcode.metadata + + original_value = metadata["original_value"] + current_value = metadata["current_value"] + if current_value is None: + current_value = original_value + new_value = metadata["new_value"] + + # Refund is provided when setting from non-zero to zero + refund = 0 + if current_value != new_value: + if original_value != 0 and current_value != 0 and new_value == 0: + # Storage is cleared for the first time in the transaction + refund += gas_costs.R_STORAGE_CLEAR + + if original_value != 0 and current_value == 0: + # Gas refund issued earlier to be reversed + refund -= gas_costs.R_STORAGE_CLEAR + + if original_value == new_value: + # Storage slot being restored to its original value + if original_value == 0: + # Slot was originally empty and was SET earlier + refund += gas_costs.G_STORAGE_SET - gas_costs.G_WARM_SLOAD + else: + # Slot was originally non-empty and was UPDATED earlier + refund += ( + gas_costs.G_STORAGE_UPDATE + - gas_costs.G_COLD_SLOAD + - gas_costs.G_WARM_SLOAD + ) + + return refund + + @classmethod + def _calculate_sstore_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Calculate SSTORE gas cost based on metadata.""" + metadata = opcode.metadata + + original_value = metadata["original_value"] + current_value = metadata["current_value"] + if current_value is None: + current_value = original_value + new_value = metadata["new_value"] + + gas_cost = 0 if metadata["key_warm"] else gas_costs.G_COLD_SLOAD + + if original_value == current_value and current_value != new_value: + if original_value == 0: + gas_cost += gas_costs.G_STORAGE_SET + else: + gas_cost += gas_costs.G_STORAGE_UPDATE - gas_costs.G_COLD_SLOAD + else: + gas_cost += gas_costs.G_WARM_SLOAD + + return gas_cost + + @classmethod + def _calculate_call_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + Calculate CALL/DELEGATECALL/STATICCALL gas cost based on metadata. + """ + metadata = opcode.metadata + + # Base cost depends on address warmth + if metadata["address_warm"]: + base_cost = gas_costs.G_WARM_ACCOUNT_ACCESS + else: + base_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + + return base_cost + + @classmethod + def _calculate_create_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """CREATE gas is constant at Frontier.""" + del opcode + return gas_costs.G_CREATE + + @classmethod + def _calculate_return_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Calculate RETURN gas cost based on metadata.""" + metadata = opcode.metadata + + # Code deposit cost when returning from initcode + code_deposit_size = metadata["code_deposit_size"] + return gas_costs.G_CODE_DEPOSIT_BYTE * code_deposit_size + + @classmethod + def _calculate_selfdestruct_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Calculate SELFDESTRUCT gas cost based on metadata.""" + metadata = opcode.metadata + + base_cost = gas_costs.G_SELF_DESTRUCT + + # Check if the beneficiary is cold + if not metadata["address_warm"]: + base_cost += gas_costs.G_COLD_ACCOUNT_ACCESS + + # Check if creating a new account + if metadata["account_new"]: + base_cost += gas_costs.G_NEW_ACCOUNT + + return base_cost + @classmethod def memory_expansion_gas_calculator( cls, *, block_number: int = 0, timestamp: int = 0 @@ -414,7 +917,8 @@ def full_blob_tx_wrapper_version( ) -> int | None: """Return the version of the full blob transaction wrapper.""" raise NotImplementedError( - f"Full blob transaction wrapper version is not supported in {cls.name()}" + "Full blob transaction wrapper version is not supported in " + f"{cls.name()}" ) @classmethod @@ -878,6 +1382,28 @@ def call_opcodes( Homestead, cls ).call_opcodes(block_number=block_number, timestamp=timestamp) + @classmethod + def opcode_gas_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """Add DELEGATECALL opcode gas cost for Homestead.""" + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + memory_expansion_calculator = cls.memory_expansion_gas_calculator( + block_number=block_number, timestamp=timestamp + ) + base_map = super(Homestead, cls).opcode_gas_map( + block_number=block_number, timestamp=timestamp + ) + return { + **base_map, + Opcodes.DELEGATECALL: cls._with_memory_expansion( + lambda op: cls._calculate_call_gas(op, gas_costs), + memory_expansion_calculator, + ), + } + @classmethod def valid_opcodes( cls, *, block_number: int = 0, timestamp: int = 0 @@ -939,10 +1465,32 @@ class Tangerine(DAOFork, ignore=True): class SpuriousDragon(Tangerine, ignore=True): """SpuriousDragon fork (EIP-155, EIP-158).""" - pass + @classmethod + def _calculate_call_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + At Spurious Dragon, the call gas cost needs to take the value transfer + and account new into account. + """ + base_cost = super(SpuriousDragon, cls)._calculate_call_gas( + opcode, gas_costs + ) + # Additional costs for value transfer, does not apply to STATICCALL + metadata = opcode.metadata + if "value_transfer" in metadata: + if metadata["value_transfer"]: + base_cost += gas_costs.G_CALL_VALUE + if metadata["account_new"]: + base_cost += gas_costs.G_NEW_ACCOUNT + elif metadata["account_new"]: + raise ValueError("Account new requires value transfer") -class Byzantium(Homestead): + return base_cost + + +class Byzantium(SpuriousDragon): """Byzantium fork.""" @classmethod @@ -994,6 +1542,36 @@ def call_opcodes( Byzantium, cls ).call_opcodes(block_number=block_number, timestamp=timestamp) + @classmethod + def opcode_gas_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """Add Byzantium opcodes gas costs.""" + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + memory_expansion_calculator = cls.memory_expansion_gas_calculator( + block_number=block_number, timestamp=timestamp + ) + base_map = super(Byzantium, cls).opcode_gas_map( + block_number=block_number, timestamp=timestamp + ) + return { + **base_map, + Opcodes.RETURNDATASIZE: gas_costs.G_BASE, + Opcodes.RETURNDATACOPY: cls._with_memory_expansion( + cls._with_data_copy(gas_costs.G_VERY_LOW, gas_costs), + memory_expansion_calculator, + ), + Opcodes.STATICCALL: cls._with_memory_expansion( + lambda op: cls._calculate_call_gas(op, gas_costs), + memory_expansion_calculator, + ), + Opcodes.REVERT: cls._with_memory_expansion( + 0, memory_expansion_calculator + ), + } + @classmethod def valid_opcodes( cls, *, block_number: int = 0, timestamp: int = 0 @@ -1037,6 +1615,20 @@ def get_reward(cls, *, block_number: int = 0, timestamp: int = 0) -> int: del block_number, timestamp return 2_000_000_000_000_000_000 + @classmethod + def _calculate_create2_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """Calculate CREATE2 gas cost based on metadata.""" + metadata = opcode.metadata + + # Keccak256 hashing cost + init_code_size = metadata["init_code_size"] + init_code_words = (init_code_size + 31) // 32 + hash_gas = gas_costs.G_KECCAK_256_WORD * init_code_words + + return gas_costs.G_CREATE + hash_gas + @classmethod def create_opcodes( cls, *, block_number: int = 0, timestamp: int = 0 @@ -1046,6 +1638,32 @@ def create_opcodes( Constantinople, cls ).create_opcodes(block_number=block_number, timestamp=timestamp) + @classmethod + def opcode_gas_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """Add Constantinople opcodes gas costs.""" + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + memory_expansion_calculator = cls.memory_expansion_gas_calculator( + block_number=block_number, timestamp=timestamp + ) + base_map = super(Constantinople, cls).opcode_gas_map( + block_number=block_number, timestamp=timestamp + ) + return { + **base_map, + Opcodes.SHL: gas_costs.G_VERY_LOW, + Opcodes.SHR: gas_costs.G_VERY_LOW, + Opcodes.SAR: gas_costs.G_VERY_LOW, + Opcodes.EXTCODEHASH: cls._with_account_access(0, gas_costs), + Opcodes.CREATE2: cls._with_memory_expansion( + lambda op: cls._calculate_create2_gas(op, gas_costs), + memory_expansion_calculator, + ), + } + @classmethod def valid_opcodes( cls, *, block_number: int = 0, timestamp: int = 0 @@ -1081,6 +1699,23 @@ def precompiles( block_number=block_number, timestamp=timestamp ) + @classmethod + def opcode_gas_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """Add Istanbul opcodes gas costs.""" + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + base_map = super(Istanbul, cls).opcode_gas_map( + block_number=block_number, timestamp=timestamp + ) + return { + **base_map, + Opcodes.CHAINID: gas_costs.G_BASE, + Opcodes.SELFBALANCE: gas_costs.G_LOW, + } + @classmethod def valid_opcodes( cls, *, block_number: int = 0, timestamp: int = 0 @@ -1209,6 +1844,22 @@ def contract_creating_tx_types( block_number=block_number, timestamp=timestamp ) + @classmethod + def opcode_gas_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """Add London opcodes gas costs.""" + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + base_map = super(London, cls).opcode_gas_map( + block_number=block_number, timestamp=timestamp + ) + return { + **base_map, + Opcodes.BASEFEE: gas_costs.G_BASE, + } + @classmethod def valid_opcodes( cls, *, block_number: int = 0, timestamp: int = 0 @@ -1460,6 +2111,66 @@ def max_initcode_size( del block_number, timestamp return 0xC000 + @classmethod + def _calculate_create_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + Calculate CREATE gas cost based on metadata (from Shanghai, includes + initcode cost). + """ + metadata = opcode.metadata + + # Get base cost from parent fork + base_cost = super(Shanghai, cls)._calculate_create_gas( + opcode, gas_costs + ) + + # Add initcode cost (EIP-3860) + init_code_size = metadata["init_code_size"] + init_code_words = (init_code_size + 31) // 32 + init_code_gas = gas_costs.G_INITCODE_WORD * init_code_words + + return base_cost + init_code_gas + + @classmethod + def _calculate_create2_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + Calculate CREATE2 gas cost based on metadata (from Shanghai, + includes initcode cost). + """ + metadata = opcode.metadata + + # Get base cost from parent fork (includes keccak hash cost) + base_cost = super(Shanghai, cls)._calculate_create2_gas( + opcode, gas_costs + ) + + # Add initcode cost (EIP-3860) + init_code_size = metadata["init_code_size"] + init_code_words = (init_code_size + 31) // 32 + init_code_gas = gas_costs.G_INITCODE_WORD * init_code_words + + return base_cost + init_code_gas + + @classmethod + def opcode_gas_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """Add Shanghai opcodes gas costs.""" + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + base_map = super(Shanghai, cls).opcode_gas_map( + block_number=block_number, timestamp=timestamp + ) + return { + **base_map, + Opcodes.PUSH0: gas_costs.G_BASE, + } + @classmethod def valid_opcodes( cls, *, block_number: int = 0, timestamp: int = 0 @@ -1478,7 +2189,9 @@ class Cancun(Shanghai): "CELL_LENGTH": 2048, # EIP-2537: Main subgroup order = q, due to this BLS_MODULUS # every blob byte (uint256) must be smaller than 116 - "BLS_MODULUS": 0x73EDA753299D7D483339D80809A1D80553BDA402FFFE5BFEFFFFFFFF00000001, + "BLS_MODULUS": ( + 0x73EDA753299D7D483339D80809A1D80553BDA402FFFE5BFEFFFFFFFF00000001 + ), # https://github.com/ethereum/consensus-specs/blob/ # cc6996c22692d70e41b7a453d925172ee4b719ad/specs/deneb/ # polynomial-commitments.md?plain=1#L78 @@ -1493,7 +2206,8 @@ def get_blob_constant(cls, name: str) -> int | Literal["big"]: """Return blob constant if it exists.""" retrieved_constant = cls.BLOB_CONSTANTS.get(name) assert retrieved_constant is not None, ( - f"You tried to retrieve the blob constant {name} but it does not exist!" + f"You tried to retrieve the blob constant {name} but it does " + "not exist!" ) return retrieved_constant @@ -1755,9 +2469,10 @@ def pre_allocation_blockchain( new_allocation = { 0x000F3DF6D732807EF1319FB7B8BB8522D0BEAC02: { "nonce": 1, - "code": "0x3373fffffffffffffffffffffffffffffffffffffffe14604d57602036146024575f5f" - "fd5b5f35801560495762001fff810690815414603c575f5ffd5b62001fff01545f5260205ff35b5f" - "5ffd5b62001fff42064281555f359062001fff015500", + "code": "0x3373fffffffffffffffffffffffffffffffffffffffe14604d" + "57602036146024575f5ffd5b5f35801560495762001fff810690" + "815414603c575f5ffd5b62001fff01545f5260205ff35b5f5ffd" + "5b62001fff42064281555f359062001fff015500", } } return new_allocation | super(Cancun, cls).pre_allocation_blockchain() # type: ignore @@ -1794,6 +2509,45 @@ def engine_new_payload_beacon_root( del block_number, timestamp return True + @classmethod + def opcode_gas_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """ + Return a mapping of opcodes to their gas costs for Cancun. + + Adds Cancun-specific opcodes: BLOBHASH, BLOBBASEFEE, TLOAD, TSTORE, + MCOPY. + """ + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + memory_expansion_calculator = cls.memory_expansion_gas_calculator( + block_number=block_number, timestamp=timestamp + ) + + # Get parent fork's opcode gas map + base_map = super(Cancun, cls).opcode_gas_map( + block_number=block_number, timestamp=timestamp + ) + + # Add Cancun-specific opcodes + return { + **base_map, + # EIP-4844: Shard Blob Transactions + Opcodes.BLOBHASH: gas_costs.G_VERY_LOW, + # EIP-7516: BLOBBASEFEE opcode + Opcodes.BLOBBASEFEE: gas_costs.G_BASE, + # EIP-1153: Transient storage opcodes + Opcodes.TLOAD: gas_costs.G_WARM_SLOAD, + Opcodes.TSTORE: gas_costs.G_WARM_SLOAD, + # EIP-5656: MCOPY - Memory copying instruction + Opcodes.MCOPY: cls._with_memory_expansion( + cls._with_data_copy(gas_costs.G_VERY_LOW, gas_costs), + memory_expansion_calculator, + ), + } + @classmethod def valid_opcodes( cls, *, block_number: int = 0, timestamp: int = 0 @@ -1941,6 +2695,26 @@ def fn(*, data: BytesConvertible, floor: bool = False) -> int: return fn + @classmethod + def _calculate_call_gas( + cls, opcode: OpcodeBase, gas_costs: GasCosts + ) -> int: + """ + At Prague, the call gas cost needs to take the authorization into + account. + """ + metadata = opcode.metadata + + base_cost = super(Prague, cls)._calculate_call_gas(opcode, gas_costs) + + if metadata["delegated_address"] or metadata["delegated_address_warm"]: + if metadata["delegated_address_warm"]: + base_cost += gas_costs.G_WARM_ACCOUNT_ACCESS + else: + base_cost += gas_costs.G_COLD_ACCOUNT_ACCESS + + return base_cost + @classmethod def transaction_data_floor_cost_calculator( cls, *, block_number: int = 0, timestamp: int = 0 @@ -2211,6 +2985,22 @@ def block_rlp_size_limit( safety_margin = 2_097_152 return max_block_size - safety_margin + @classmethod + def opcode_gas_map( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> Dict[OpcodeBase, int | Callable[[OpcodeBase], int]]: + """Add Osaka opcodes gas costs.""" + gas_costs = cls.gas_costs( + block_number=block_number, timestamp=timestamp + ) + base_map = super(Osaka, cls).opcode_gas_map( + block_number=block_number, timestamp=timestamp + ) + return { + **base_map, + Opcodes.CLZ: gas_costs.G_LOW, + } + @classmethod def valid_opcodes( cls, *, block_number: int = 0, timestamp: int = 0 diff --git a/packages/testing/src/execution_testing/forks/gas_costs.py b/packages/testing/src/execution_testing/forks/gas_costs.py index 7b73fc5f96..a94d4f0c96 100644 --- a/packages/testing/src/execution_testing/forks/gas_costs.py +++ b/packages/testing/src/execution_testing/forks/gas_costs.py @@ -20,6 +20,7 @@ class GasCosts: G_WARM_SLOAD: int G_COLD_SLOAD: int G_STORAGE_SET: int + G_STORAGE_UPDATE: int G_STORAGE_RESET: int R_STORAGE_CLEAR: int diff --git a/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py b/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py new file mode 100644 index 0000000000..6391429a37 --- /dev/null +++ b/packages/testing/src/execution_testing/forks/tests/test_opcode_gas_costs.py @@ -0,0 +1,602 @@ +"""Test opcode gas costs.""" + +import pytest + +from execution_testing.vm import Bytecode, Op + +from ..forks.forks import Osaka, Homestead +from ..helpers import Fork + + +@pytest.mark.parametrize( + "fork,opcode,expected_cost", + [ + pytest.param( + Osaka, + Op.MSTORE(new_memory_size=1), + Osaka.memory_expansion_gas_calculator()(new_bytes=1) + + Osaka.gas_costs().G_VERY_LOW, + id="mstore_memory_expansion", + ), + pytest.param( + Osaka, + Op.SSTORE, + Osaka.gas_costs().G_STORAGE_SET + Osaka.gas_costs().G_COLD_SLOAD, + id="sstore_defaults", + ), + pytest.param( + Osaka, + Op.SSTORE(key_warm=True), + Osaka.gas_costs().G_STORAGE_SET, + id="sstore_warm_key", + ), + # EXP tests + pytest.param( + Osaka, + Op.EXP(exponent=0), + Osaka.gas_costs().G_EXP, + id="exp_zero_exponent", + ), + pytest.param( + Osaka, + Op.EXP(exponent=0xFFFFFF), # 3 bytes + Osaka.gas_costs().G_EXP + Osaka.gas_costs().G_EXP_BYTE * 3, + id="exp_three_bytes", + ), + pytest.param( + Osaka, + Op.EXP(exponent=0x1FFFFFF), # 3 bytes + Osaka.gas_costs().G_EXP + Osaka.gas_costs().G_EXP_BYTE * 4, + id="exp_three_bytes_plus_one_bit", + ), + # SHA3 tests + pytest.param( + Osaka, + Op.SHA3(data_size=0), + Osaka.gas_costs().G_KECCAK_256, + id="sha3_zero_data", + ), + pytest.param( + Osaka, + Op.SHA3(data_size=64, new_memory_size=96), + Osaka.gas_costs().G_KECCAK_256 + + Osaka.gas_costs().G_KECCAK_256_WORD * 2 + + Osaka.memory_expansion_gas_calculator()(new_bytes=96), + id="sha3_with_data_and_memory", + ), + # BALANCE tests + pytest.param( + Osaka, + Op.BALANCE(address_warm=False), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS, + id="balance_cold_address", + ), + pytest.param( + Osaka, + Op.BALANCE(address_warm=True), + Osaka.gas_costs().G_WARM_ACCOUNT_ACCESS, + id="balance_warm_address", + ), + # CALLDATACOPY tests + pytest.param( + Osaka, + Op.CALLDATACOPY(data_size=32, new_memory_size=32), + Osaka.gas_costs().G_VERY_LOW + + Osaka.gas_costs().G_COPY * 1 + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="calldatacopy_one_word", + ), + pytest.param( + Osaka, + Op.CALLDATACOPY( + data_size=64, new_memory_size=64, old_memory_size=32 + ), + Osaka.gas_costs().G_VERY_LOW + + Osaka.gas_costs().G_COPY * 2 + + Osaka.memory_expansion_gas_calculator()( + new_bytes=64, previous_bytes=32 + ), + id="calldatacopy_expansion", + ), + # CODECOPY tests + pytest.param( + Osaka, + Op.CODECOPY(data_size=96, new_memory_size=96), + Osaka.gas_costs().G_VERY_LOW + + Osaka.gas_costs().G_COPY * 3 + + Osaka.memory_expansion_gas_calculator()(new_bytes=96), + id="codecopy_three_words", + ), + # EXTCODESIZE tests + pytest.param( + Osaka, + Op.EXTCODESIZE(address_warm=False), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS, + id="extcodesize_cold", + ), + pytest.param( + Osaka, + Op.EXTCODESIZE(address_warm=True), + Osaka.gas_costs().G_WARM_ACCOUNT_ACCESS, + id="extcodesize_warm", + ), + # EXTCODECOPY tests + pytest.param( + Osaka, + Op.EXTCODECOPY( + address_warm=True, data_size=32, new_memory_size=32 + ), + Osaka.gas_costs().G_WARM_ACCOUNT_ACCESS + + Osaka.gas_costs().G_COPY * 1 + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="extcodecopy_warm", + ), + pytest.param( + Osaka, + Op.EXTCODECOPY( + address_warm=False, data_size=64, new_memory_size=64 + ), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS + + Osaka.gas_costs().G_COPY * 2 + + Osaka.memory_expansion_gas_calculator()(new_bytes=64), + id="extcodecopy_cold", + ), + # EXTCODEHASH tests + pytest.param( + Osaka, + Op.EXTCODEHASH(address_warm=False), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS, + id="extcodehash_cold", + ), + pytest.param( + Osaka, + Op.EXTCODEHASH(address_warm=True), + Osaka.gas_costs().G_WARM_ACCOUNT_ACCESS, + id="extcodehash_warm", + ), + # RETURNDATACOPY tests + pytest.param( + Osaka, + Op.RETURNDATACOPY(data_size=32, new_memory_size=32), + Osaka.gas_costs().G_VERY_LOW + + Osaka.gas_costs().G_COPY * 1 + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="returndatacopy", + ), + # MLOAD tests + pytest.param( + Osaka, + Op.MLOAD(new_memory_size=32), + Osaka.gas_costs().G_VERY_LOW + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="mload_memory_expansion", + ), + # MSTORE8 tests + pytest.param( + Osaka, + Op.MSTORE8(new_memory_size=1), + Osaka.gas_costs().G_VERY_LOW + + Osaka.memory_expansion_gas_calculator()(new_bytes=1), + id="mstore8_memory_expansion", + ), + # SLOAD tests + pytest.param( + Osaka, + Op.SLOAD(key_warm=False), + Osaka.gas_costs().G_COLD_SLOAD, + id="sload_cold", + ), + pytest.param( + Osaka, + Op.SLOAD(key_warm=True), + Osaka.gas_costs().G_WARM_SLOAD, + id="sload_warm", + ), + # MCOPY tests + pytest.param( + Osaka, + Op.MCOPY(data_size=32, new_memory_size=32), + Osaka.gas_costs().G_VERY_LOW + + Osaka.gas_costs().G_COPY * 1 + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="mcopy_one_word", + ), + pytest.param( + Osaka, + Op.MCOPY(data_size=96, new_memory_size=128, old_memory_size=64), + Osaka.gas_costs().G_VERY_LOW + + Osaka.gas_costs().G_COPY * 3 + + Osaka.memory_expansion_gas_calculator()( + new_bytes=128, previous_bytes=64 + ), + id="mcopy_expansion", + ), + # LOG0 tests + pytest.param( + Osaka, + Op.LOG0(data_size=32, new_memory_size=32), + Osaka.gas_costs().G_LOG + + Osaka.gas_costs().G_LOG_DATA * 32 + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="log0", + ), + # LOG1 tests + pytest.param( + Osaka, + Op.LOG1(data_size=64, new_memory_size=64), + Osaka.gas_costs().G_LOG + + Osaka.gas_costs().G_LOG_DATA * 64 + + Osaka.gas_costs().G_LOG_TOPIC + + Osaka.memory_expansion_gas_calculator()(new_bytes=64), + id="log1", + ), + # LOG2 tests + pytest.param( + Osaka, + Op.LOG2(data_size=128, new_memory_size=128), + Osaka.gas_costs().G_LOG + + Osaka.gas_costs().G_LOG_DATA * 128 + + Osaka.gas_costs().G_LOG_TOPIC * 2 + + Osaka.memory_expansion_gas_calculator()(new_bytes=128), + id="log2", + ), + # LOG3 tests + pytest.param( + Osaka, + Op.LOG3(data_size=256, new_memory_size=256), + Osaka.gas_costs().G_LOG + + Osaka.gas_costs().G_LOG_DATA * 256 + + Osaka.gas_costs().G_LOG_TOPIC * 3 + + Osaka.memory_expansion_gas_calculator()(new_bytes=256), + id="log3", + ), + # LOG4 tests + pytest.param( + Osaka, + Op.LOG4(data_size=512, new_memory_size=512), + Osaka.gas_costs().G_LOG + + Osaka.gas_costs().G_LOG_DATA * 512 + + Osaka.gas_costs().G_LOG_TOPIC * 4 + + Osaka.memory_expansion_gas_calculator()(new_bytes=512), + id="log4", + ), + # CREATE tests + pytest.param( + Osaka, + Op.CREATE(init_code_size=100, new_memory_size=100), + Osaka.gas_costs().G_CREATE + + Osaka.gas_costs().G_INITCODE_WORD * 4 # (100 + 31) // 32 = 4 + + Osaka.memory_expansion_gas_calculator()(new_bytes=100), + id="create_with_initcode", + ), + # CREATE2 tests + pytest.param( + Osaka, + Op.CREATE2(init_code_size=64, new_memory_size=64), + Osaka.gas_costs().G_CREATE + + Osaka.gas_costs().G_INITCODE_WORD * 2 + + Osaka.gas_costs().G_KECCAK_256_WORD * 2 + + Osaka.memory_expansion_gas_calculator()(new_bytes=64), + id="create2_with_initcode_and_hash", + ), + # CALL tests + pytest.param( + Osaka, + Op.CALL( + address_warm=True, value_transfer=False, new_memory_size=64 + ), + Osaka.gas_costs().G_WARM_ACCOUNT_ACCESS + + Osaka.memory_expansion_gas_calculator()(new_bytes=64), + id="call_warm_no_value", + ), + pytest.param( + Osaka, + Op.CALL(address_warm=False, delegated_address=True), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS + + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS, + id="call_cold_delegated_address", + ), + pytest.param( + Osaka, + Op.CALL( + address_warm=False, + delegated_address=True, + delegated_address_warm=True, + ), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS + + Osaka.gas_costs().G_WARM_ACCOUNT_ACCESS, + id="call_warm_delegated_address", + ), + pytest.param( + Osaka, + Op.CALL(address_warm=False, value_transfer=True, account_new=True), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS + + Osaka.gas_costs().G_CALL_VALUE + + Osaka.gas_costs().G_NEW_ACCOUNT, + id="call_cold_account_new", + ), + pytest.param( + Homestead, + Op.CALL(address_warm=False, value_transfer=True, account_new=True), + Homestead.gas_costs().G_COLD_ACCOUNT_ACCESS, + id="call_cold_account_new_homestead", + ), + pytest.param( + Osaka, + Op.CALL( + address_warm=False, + value_transfer=True, + account_new=False, + new_memory_size=32, + ), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS + + Osaka.gas_costs().G_CALL_VALUE + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="call_cold_with_value", + ), + pytest.param( + Osaka, + Op.CALL( + address_warm=False, + value_transfer=True, + account_new=True, + new_memory_size=32, + ), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS + + Osaka.gas_costs().G_CALL_VALUE + + Osaka.gas_costs().G_NEW_ACCOUNT + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="call_cold_new_account", + ), + # CALLCODE tests + pytest.param( + Osaka, + Op.CALLCODE( + address_warm=True, value_transfer=False, new_memory_size=32 + ), + Osaka.gas_costs().G_WARM_ACCOUNT_ACCESS + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="callcode_warm", + ), + # DELEGATECALL tests + pytest.param( + Osaka, + Op.DELEGATECALL(address_warm=True, new_memory_size=32), + Osaka.gas_costs().G_WARM_ACCOUNT_ACCESS + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="delegatecall_warm", + ), + pytest.param( + Osaka, + Op.DELEGATECALL(address_warm=False, new_memory_size=64), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS + + Osaka.memory_expansion_gas_calculator()(new_bytes=64), + id="delegatecall_cold", + ), + # STATICCALL tests + pytest.param( + Osaka, + Op.STATICCALL(address_warm=True, new_memory_size=32), + Osaka.gas_costs().G_WARM_ACCOUNT_ACCESS + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="staticcall_warm", + ), + pytest.param( + Osaka, + Op.STATICCALL(address_warm=False, new_memory_size=0), + Osaka.gas_costs().G_COLD_ACCOUNT_ACCESS, + id="staticcall_cold_no_memory", + ), + # RETURN tests + pytest.param( + Osaka, + Op.RETURN(new_memory_size=32), + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="return_no_deposit", + ), + pytest.param( + Osaka, + Op.RETURN(code_deposit_size=100, new_memory_size=32), + Osaka.gas_costs().G_CODE_DEPOSIT_BYTE * 100 + + Osaka.memory_expansion_gas_calculator()(new_bytes=32), + id="return_with_code_deposit", + ), + # REVERT tests + pytest.param( + Osaka, + Op.REVERT(new_memory_size=64), + Osaka.memory_expansion_gas_calculator()(new_bytes=64), + id="revert_memory_expansion", + ), + # CLZ test (Osaka-specific) + pytest.param( + Osaka, + Op.CLZ, + Osaka.gas_costs().G_LOW, + id="clz_osaka", + ), + ], +) +def test_opcode_gas_costs(fork: Fork, opcode: Op, expected_cost: int) -> None: + op_gas_cost_calc = fork.opcode_gas_calculator() + assert expected_cost == op_gas_cost_calc(opcode) + + +@pytest.mark.parametrize( + "fork,bytecode,expected_cost", + [ + pytest.param( + Osaka, + Op.ADD + Op.SUB, + Osaka.gas_costs().G_VERY_LOW * 2, + id="sum_of_opcodes", + ), + pytest.param( + Osaka, + Op.ADD(1, 1), + Osaka.gas_costs().G_VERY_LOW * 3, + id="opcode_with_args", + ), + pytest.param( + Osaka, + Op.SSTORE(1, 2, key_warm=True), + Osaka.gas_costs().G_STORAGE_SET + Osaka.gas_costs().G_VERY_LOW * 2, + id="opcode_with_metadata", + ), + ], +) +def test_bytecode_gas_costs( + fork: Fork, bytecode: Bytecode, expected_cost: int +) -> None: + assert expected_cost == bytecode.gas_cost(fork) + + +@pytest.mark.parametrize( + "fork,opcode,expected_refund", + [ + pytest.param( + Osaka, + Op.SSTORE(original_value=0, new_value=0), + 0, + id="sstore_no_refund_zero_to_zero", + ), + pytest.param( + Osaka, + Op.SSTORE(original_value=1, new_value=1), + 0, + id="sstore_no_refund_nonzero_to_nonzero", + ), + pytest.param( + Osaka, + Op.SSTORE(original_value=1, new_value=0), + Osaka.gas_costs().R_STORAGE_CLEAR, + id="sstore_refund_clear_storage", + ), + pytest.param( + Osaka, + Op.ADD, + 0, + id="add_no_refund", + ), + pytest.param( + Osaka, + Op.MSTORE, + 0, + id="mstore_no_refund", + ), + ], +) +def test_opcode_refunds(fork: Fork, opcode: Op, expected_refund: int) -> None: + op_refund_calc = fork.opcode_refund_calculator() + assert expected_refund == op_refund_calc(opcode) + + +@pytest.mark.parametrize( + "fork,bytecode,expected_refund", + [ + pytest.param( + Osaka, + Op.SSTORE(original_value=1, new_value=0), + Osaka.gas_costs().R_STORAGE_CLEAR, + id="single_sstore_clear", + ), + pytest.param( + Osaka, + Op.SSTORE(original_value=2, new_value=0) + + Op.SSTORE(original_value=1, new_value=0), + Osaka.gas_costs().R_STORAGE_CLEAR * 2, + id="double_sstore_clear", + ), + pytest.param( + Osaka, + Op.SSTORE(original_value=1, new_value=2) + + Op.SSTORE(original_value=1, new_value=0), + Osaka.gas_costs().R_STORAGE_CLEAR, + id="mixed_sstore_one_clear", + ), + pytest.param( + Osaka, + Op.ADD + Op.SUB, + 0, + id="no_refund_opcodes", + ), + ], +) +def test_bytecode_refunds( + fork: Fork, bytecode: Bytecode, expected_refund: int +) -> None: + assert expected_refund == bytecode.refund(fork) + + +@pytest.mark.parametrize( + "fork,opcode,expected_cost", + [ + # No-op: new == current (value_reset=True on clean slot) + pytest.param( + Osaka, + Op.SSTORE(key_warm=True, original_value=0, new_value=0), + Osaka.gas_costs().G_WARM_SLOAD, + id="sstore_noop_zero_warm", # 0 → 0 + ), + pytest.param( + Osaka, + Op.SSTORE(key_warm=False, original_value=0, new_value=0), + Osaka.gas_costs().G_COLD_SLOAD + Osaka.gas_costs().G_WARM_SLOAD, + id="sstore_noop_zero_cold", # 0 → 0 + ), + pytest.param( + Osaka, + Op.SSTORE(key_warm=True, original_value=5, new_value=5), + Osaka.gas_costs().G_WARM_SLOAD, + id="sstore_noop_nonzero_warm", # 5 → 5 + ), + pytest.param( + Osaka, + Op.SSTORE(key_warm=False, original_value=5, new_value=5), + Osaka.gas_costs().G_COLD_SLOAD + Osaka.gas_costs().G_WARM_SLOAD, + id="sstore_noop_nonzero_cold", # 5 → 5 + ), + # Create storage: 0 → X (original == 0) + pytest.param( + Osaka, + Op.SSTORE(key_warm=True, new_value=5), + Osaka.gas_costs().G_STORAGE_SET, + id="sstore_create_warm", # 0 → 5 + ), + pytest.param( + Osaka, + Op.SSTORE(key_warm=False, new_value=5), + Osaka.gas_costs().G_COLD_SLOAD + Osaka.gas_costs().G_STORAGE_SET, + id="sstore_create_cold", # 0 → 5 + ), + # Modify storage: X → Y (original != 0, new != 0, new != original) + pytest.param( + Osaka, + Op.SSTORE(key_warm=True, original_value=5, new_value=7), + Osaka.gas_costs().G_STORAGE_RESET, + id="sstore_modify_warm", # 5 → 7 + ), + pytest.param( + Osaka, + Op.SSTORE(key_warm=False, original_value=5, new_value=7), + Osaka.gas_costs().G_COLD_SLOAD + Osaka.gas_costs().G_STORAGE_RESET, + id="sstore_modify_cold", # 5 → 7 + ), + # Clear storage: X → 0 (original != 0, new == 0) + pytest.param( + Osaka, + Op.SSTORE(key_warm=True, original_value=5, new_value=0), + Osaka.gas_costs().G_STORAGE_RESET, + id="sstore_clear_warm", # 5 → 0 + ), + pytest.param( + Osaka, + Op.SSTORE(key_warm=False, original_value=5, new_value=0), + Osaka.gas_costs().G_COLD_SLOAD + Osaka.gas_costs().G_STORAGE_RESET, + id="sstore_clear_cold", # 5 → 0 + ), + ], +) +def test_sstore_gas_costs(fork: Fork, opcode: Op, expected_cost: int) -> None: + """Test SSTORE gas costs for all single-SSTORE scenarios.""" + assert opcode.gas_cost(fork) == expected_cost diff --git a/packages/testing/src/execution_testing/tools/utility/generators.py b/packages/testing/src/execution_testing/tools/utility/generators.py index 0ff5cc4c0b..ef50b67ec7 100644 --- a/packages/testing/src/execution_testing/tools/utility/generators.py +++ b/packages/testing/src/execution_testing/tools/utility/generators.py @@ -446,8 +446,9 @@ def gas_test( pre: Alloc, setup_code: Bytecode, subject_code: Bytecode, + subject_code_warm: Bytecode | None = None, tear_down_code: Bytecode | None = None, - cold_gas: int, + cold_gas: int | None = None, warm_gas: int | None = None, subject_address: Address | None = None, subject_balance: int = 0, @@ -468,12 +469,18 @@ def gas_test( "Gas tests before Berlin are not supported due to CALL gas changes" ) + if cold_gas is None: + cold_gas = subject_code.gas_cost(fork) + if cold_gas <= 0: raise ValueError( f"Target gas allocations (cold_gas) must be > 0, got {cold_gas}" ) if warm_gas is None: - warm_gas = cold_gas + if subject_code_warm is not None: + warm_gas = subject_code_warm.gas_cost(fork) + else: + warm_gas = cold_gas sender = pre.fund_eoa() if tear_down_code is None: diff --git a/packages/testing/src/execution_testing/vm/__init__.py b/packages/testing/src/execution_testing/vm/__init__.py index 2305418904..3f9332ac62 100644 --- a/packages/testing/src/execution_testing/vm/__init__.py +++ b/packages/testing/src/execution_testing/vm/__init__.py @@ -1,5 +1,10 @@ """Ethereum Virtual Machine related definitions and utilities.""" +from .bases import ( + ForkOpcodeInterface, + OpcodeBase, + OpcodeGasCalculator, +) from .bytecode import Bytecode from .evm_types import EVMCodeType from .helpers import MemoryVariable, call_return_code @@ -18,12 +23,15 @@ __all__ = ( "Bytecode", "EVMCodeType", + "ForkOpcodeInterface", "Macro", "Macros", "MemoryVariable", "Op", "Opcode", + "OpcodeBase", "OpcodeCallArg", + "OpcodeGasCalculator", "Opcodes", "UndefinedOpcodes", "call_return_code", diff --git a/packages/testing/src/execution_testing/vm/bases.py b/packages/testing/src/execution_testing/vm/bases.py new file mode 100644 index 0000000000..d2f0e06c95 --- /dev/null +++ b/packages/testing/src/execution_testing/vm/bases.py @@ -0,0 +1,52 @@ +"""Base classes for the EVM.""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, Protocol + + +class OpcodeBase: + """Base class for the opcode type.""" + + metadata: Dict[str, Any] + _name_: str = "" + + def __bytes__(self) -> bytes: + """Return the opcode byte representation.""" + raise NotImplementedError("OpcodeBase does not implement __bytes__") + + +class OpcodeGasCalculator(Protocol): + """ + A protocol to calculate the cost or refund of a single opcode. + """ + + def __call__(self, opcode: OpcodeBase) -> int: + """Return the gas cost or refund for executing the given opcode.""" + pass + + +class ForkOpcodeInterface(ABC): + """ + Interface for a fork that is used to calculate opcode gas costs + and refunds. + """ + + @classmethod + @abstractmethod + def opcode_gas_calculator( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> OpcodeGasCalculator: + """ + Return callable that calculates the gas cost of a single opcode. + """ + pass + + @classmethod + @abstractmethod + def opcode_refund_calculator( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> OpcodeGasCalculator: + """ + Return callable that calculates the gas refund of a single opcode. + """ + pass diff --git a/packages/testing/src/execution_testing/vm/bytecode.py b/packages/testing/src/execution_testing/vm/bytecode.py index 6ee1b6014a..6c8dc984f9 100644 --- a/packages/testing/src/execution_testing/vm/bytecode.py +++ b/packages/testing/src/execution_testing/vm/bytecode.py @@ -1,6 +1,6 @@ """Ethereum Virtual Machine bytecode primitives and utilities.""" -from typing import Any, Self, SupportsBytes +from typing import Any, List, Self, SupportsBytes, Type from pydantic import GetCoreSchemaHandler from pydantic_core.core_schema import ( @@ -11,6 +11,8 @@ from execution_testing.base_types import Bytes, Hash +from .bases import ForkOpcodeInterface, OpcodeBase + class Bytecode: """ @@ -38,6 +40,7 @@ class Bytecode: min_stack_height: int terminating: bool + opcode_list: List[OpcodeBase] def __new__( cls, @@ -49,8 +52,11 @@ def __new__( min_stack_height: int | None = None, terminating: bool = False, name: str = "", + opcode_list: List[OpcodeBase] | None = None, ) -> Self: """Create new opcode instance.""" + if opcode_list is None: + opcode_list = [] if bytes_or_byte_code_base is None: instance = super().__new__(cls) instance._bytes_ = b"" @@ -60,6 +66,7 @@ def __new__( instance.max_stack_height = 0 instance.terminating = False instance._name_ = name + instance.opcode_list = opcode_list return instance if isinstance(bytes_or_byte_code_base, Bytecode): @@ -72,6 +79,7 @@ def __new__( obj.min_stack_height = bytes_or_byte_code_base.min_stack_height obj.max_stack_height = bytes_or_byte_code_base.max_stack_height obj.terminating = bytes_or_byte_code_base.terminating + obj.opcode_list = bytes_or_byte_code_base.opcode_list[:] obj._name_ = bytes_or_byte_code_base._name_ return obj @@ -93,6 +101,7 @@ def __new__( else: obj.max_stack_height = max_stack_height obj.terminating = terminating + obj.opcode_list = opcode_list obj._name_ = name return obj @@ -215,6 +224,7 @@ def __add__(self, other: "Bytecode | bytes | int | None") -> "Bytecode": min_stack_height=c_min, max_stack_height=c_max, terminating=other.terminating, + opcode_list=self.opcode_list + other.opcode_list, ) def __radd__(self, other: "Bytecode | int | None") -> "Bytecode": @@ -253,6 +263,38 @@ def keccak256(self) -> Hash: """Return the keccak256 hash of the opcode byte representation.""" return Bytes(self._bytes_).keccak256() + def gas_cost( + self, + fork: Type[ForkOpcodeInterface], + *, + block_number: int = 0, + timestamp: int = 0, + ) -> int: + """Use a fork object to calculate the gas used by this bytecode.""" + opcode_gas_calculator = fork.opcode_gas_calculator( + block_number=block_number, timestamp=timestamp + ) + total_gas = 0 + for opcode in self.opcode_list: + total_gas += opcode_gas_calculator(opcode) + return total_gas + + def refund( + self, + fork: Type[ForkOpcodeInterface], + *, + block_number: int = 0, + timestamp: int = 0, + ) -> int: + """Use a fork object to calculate the gas refund from this bytecode.""" + opcode_refund_calculator = fork.opcode_refund_calculator( + block_number=block_number, timestamp=timestamp + ) + total_refund = 0 + for opcode in self.opcode_list: + total_refund += opcode_refund_calculator(opcode) + return total_refund + @classmethod def __get_pydantic_core_schema__( cls, source_type: Any, handler: GetCoreSchemaHandler diff --git a/packages/testing/src/execution_testing/vm/opcodes.py b/packages/testing/src/execution_testing/vm/opcodes.py index a05295d49c..7d79d28c96 100644 --- a/packages/testing/src/execution_testing/vm/opcodes.py +++ b/packages/testing/src/execution_testing/vm/opcodes.py @@ -13,6 +13,7 @@ from typing import ( Any, Callable, + Dict, Iterable, List, Mapping, @@ -22,6 +23,7 @@ from execution_testing.base_types import to_bytes +from .bases import OpcodeBase from .bytecode import Bytecode @@ -42,7 +44,9 @@ def _get_int_size(n: int) -> int: def _stack_argument_to_bytecode( - arg: "int | bytes | SupportsBytes | str | Opcode | Bytecode | Iterable[int]", + arg: ( + "int | bytes | SupportsBytes | str | Opcode | Bytecode | Iterable[int]" + ), ) -> Bytecode: """Convert stack argument in an opcode or macro to bytecode.""" if isinstance(arg, Bytecode): @@ -78,7 +82,7 @@ def _stack_argument_to_bytecode( return new_opcode -class Opcode(Bytecode): +class Opcode(Bytecode, OpcodeBase): """ Represents a single Opcode instruction in the EVM, with extra metadata useful to parametrize tests. @@ -97,6 +101,8 @@ class Opcode(Bytecode): otherwise 0 - unchecked_stack: whether the bytecode should ignore stack checks when being called + - metadata: dictionary containing extra metadata about the opcode instance, + useful for gas cost calculations and other analysis """ @@ -107,6 +113,7 @@ class Opcode(Bytecode): ] kwargs: List[str] kwargs_defaults: KW_ARGS_DEFAULTS_TYPE + original_opcode: Optional["Opcode"] = None unchecked_stack: bool = False def __new__( @@ -123,11 +130,15 @@ def __new__( unchecked_stack: bool = False, terminating: bool = False, kwargs: List[str] | None = None, - kwargs_defaults: Optional[KW_ARGS_DEFAULTS_TYPE] = None, + kwargs_defaults: KW_ARGS_DEFAULTS_TYPE | None = None, + metadata: Dict[str, Any] | None = None, + original_opcode: Optional["Opcode"] = None, ) -> "Opcode": """Create new opcode instance.""" if kwargs_defaults is None: kwargs_defaults = {} + if metadata is None: + metadata = {} if type(opcode_or_byte) is Opcode: # Required because Enum class calls the base class # with the instantiated object as parameter. @@ -165,6 +176,9 @@ def __new__( else: obj.kwargs = kwargs obj.kwargs_defaults = kwargs_defaults + obj.metadata = metadata + obj.original_opcode = original_opcode + obj.opcode_list = [obj] return obj raise TypeError( "Opcode constructor '__new__' didn't return an instance!" @@ -224,7 +238,8 @@ def __getitem__( ) else: raise TypeError( - "Opcode data portion must be either an int or bytes/hex string" + "Opcode data portion must be either an int or bytes/hex " + "string" ) popped_stack_items = self.popped_stack_items pushed_stack_items = self.pushed_stack_items @@ -255,16 +270,69 @@ def __getitem__( terminating=self.terminating, kwargs=self.kwargs, kwargs_defaults=self.kwargs_defaults, + metadata=self.metadata, + original_opcode=self, ) + new_opcode.opcode_list = [new_opcode] new_opcode._name_ = f"{self._name_}_0x{data_portion.hex()}" return new_opcode + def with_metadata(self, **metadata: Any) -> "Opcode": + """ + Create a copy of this opcode with updated metadata. + + Validates metadata keys against metadata and merges with existing + metadata. + + Args: + **metadata: Metadata key-value pairs to set or update + + Returns: + A new Opcode instance with the updated metadata + + Raises: + ValueError: If invalid metadata keys are provided + + Example: + >>> warm_sstore = Op.SSTORE.with_metadata(key_warm=True, + new_value=2) + + """ + # Validate metadata keys + for key in metadata: + if key not in self.metadata: + raise ValueError( + f"Invalid metadata key '{key}' for opcode {self._name_}. " + f"Valid metadata keys: {list(self.metadata.keys())}" + ) + + # Create a new opcode instance with updated metadata + new_opcode = Opcode( + bytes(self), + popped_stack_items=self.popped_stack_items, + pushed_stack_items=self.pushed_stack_items, + min_stack_height=self.min_stack_height, + max_stack_height=self.max_stack_height, + data_portion_length=self.data_portion_length, + data_portion_formatter=self.data_portion_formatter, + unchecked_stack=self.unchecked_stack, + terminating=self.terminating, + kwargs=self.kwargs, + kwargs_defaults=self.kwargs_defaults, + # Merge defaults, existing metadata, and new metadata + metadata={**self.metadata, **metadata}, + original_opcode=self, + ) + new_opcode.opcode_list = [new_opcode] + new_opcode._name_ = self._name_ + return new_opcode + def __call__( self, *args_t: "int | bytes | str | Opcode | Bytecode | Iterable[int]", unchecked: bool = False, **kwargs: "int | bytes | str | Opcode | Bytecode", - ) -> Bytecode: + ) -> "Bytecode | Opcode": """ Make all opcode instances callable to return formatted bytecode, which constitutes a data portion, that is located after the opcode byte, @@ -297,50 +365,64 @@ def __call__( args: List["int | bytes | str | Opcode | Bytecode | Iterable[int]"] = ( list(args_t) ) - - if self.has_data_portion(): + opcode = self + + # handle metadata first + metadata = {} + for key in opcode.metadata: + if key in kwargs: + metadata[key] = kwargs.pop(key) + if metadata: + opcode = opcode.with_metadata(**metadata) + if len(args) == 0 and len(kwargs) == 0: + # Nothing else to do, return + return opcode + + if opcode.has_data_portion(): if len(args) == 0: raise ValueError( "Opcode with data portion requires at least one argument" ) - assert type(self) is Opcode + assert type(opcode) is Opcode get_item_arg = args.pop() assert not isinstance(get_item_arg, Bytecode) - return self[get_item_arg](*args) + return opcode[get_item_arg](*args) - if self.kwargs is not None and len(kwargs) > 0: + if opcode.kwargs is not None and len(kwargs) > 0: assert len(args) == 0, ( f"Cannot mix positional and keyword arguments {args} {kwargs}" ) # Validate that all provided kwargs are valid - invalid_kwargs = set(kwargs.keys()) - set(self.kwargs) + invalid_kwargs = set(kwargs.keys()) - set(opcode.kwargs) if invalid_kwargs: raise ValueError( - f"Invalid keyword argument(s) {list(invalid_kwargs)} for opcode " - f"{self._name_}. Valid arguments are: {self.kwargs}" + f"Invalid keyword argument(s) {list(invalid_kwargs)} for " + f"opcode {opcode._name_}. " + f"Valid arguments are: {opcode.kwargs}" ) - for kw in self.kwargs: + for kw in opcode.kwargs: args.append( kwargs[kw] if kw in kwargs - else self.kwargs_defaults.get(kw, 0) + else opcode.kwargs_defaults.get(kw, 0) ) # The rest of the arguments form the stack. - if len(args) != self.popped_stack_items and not ( - unchecked or self.unchecked_stack + if len(args) != opcode.popped_stack_items and not ( + unchecked or opcode.unchecked_stack ): raise ValueError( - f"Opcode {self._name_} requires {self.popped_stack_items} stack elements, but " - f"{len(args)} were provided. Use 'unchecked=True' parameter to ignore this check." + f"Opcode {opcode._name_} requires {opcode.popped_stack_items} " + f"stack elements, but {len(args)} were provided. " + "Use 'unchecked=True' parameter to ignore this check." ) pre_opcode_bytecode = Bytecode() while len(args) > 0: pre_opcode_bytecode += _stack_argument_to_bytecode(args.pop()) - return pre_opcode_bytecode + self + return pre_opcode_bytecode + opcode def __lt__(self, other: "Opcode") -> bool: """Compare two opcodes by their integer value.""" @@ -350,6 +432,25 @@ def __gt__(self, other: "Opcode") -> bool: """Compare two opcodes by their integer value.""" return self.int() > other.int() + def get_original_opcode(self) -> "Opcode": + """Return the original opcode instance.""" + if self.original_opcode is not None: + return self.original_opcode + return self + + def __hash__(self) -> int: + """Hash the opcode by its integer value.""" + return hash(self.get_original_opcode().int()) + + def __eq__(self, other: object) -> bool: + """Compare two opcodes by their integer value.""" + if isinstance(other, Opcode): + return ( + self.get_original_opcode().int() + == other.get_original_opcode().int() + ) + return super().__eq__(other) + def int(self) -> int: """Return integer representation of the opcode.""" return int.from_bytes(self, byteorder="big") @@ -361,6 +462,16 @@ def has_data_portion(self) -> bool: or self.data_portion_formatter is not None ) + def get_metadata(self) -> Dict[str, Any]: + """ + Get a copy of the current metadata. + + Returns: + A dictionary containing the current metadata values + + """ + return self.metadata.copy() + OpcodeCallArg = int | bytes | str | Bytecode | Iterable[int] @@ -798,7 +909,12 @@ class Opcodes(Opcode, Enum): Source: [evm.codes/#09](https://www.evm.codes/#09) """ - EXP = Opcode(0x0A, popped_stack_items=2, pushed_stack_items=1) + EXP = Opcode( + 0x0A, + popped_stack_items=2, + pushed_stack_items=1, + metadata={"exponent": 0}, + ) """ EXP(a, exponent) = a ** exponent ---- @@ -825,6 +941,10 @@ class Opcodes(Opcode, Enum): - static_gas = 10 - dynamic_gas = 50 * exponent_byte_size + Metadata + ---- + - exponent: the exponent value (default: 0) + Source: [evm.codes/#0A](https://www.evm.codes/#0A) """ @@ -1295,6 +1415,7 @@ class Opcodes(Opcode, Enum): popped_stack_items=2, pushed_stack_items=1, kwargs=["offset", "size"], + metadata={"data_size": 0, "new_memory_size": 0, "old_memory_size": 0}, ) """ SHA3(offset, size) = hash @@ -1323,6 +1444,12 @@ class Opcodes(Opcode, Enum): - static_gas = 30 - dynamic_gas = 6 * minimum_word_size + memory_expansion_cost + Metadata + ---- + - data_size: number of bytes being hashed (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#20](https://www.evm.codes/#20) """ @@ -1355,7 +1482,11 @@ class Opcodes(Opcode, Enum): """ BALANCE = Opcode( - 0x31, popped_stack_items=1, pushed_stack_items=1, kwargs=["address"] + 0x31, + popped_stack_items=1, + pushed_stack_items=1, + kwargs=["address"], + metadata={"address_warm": False}, ) """ BALANCE(address) = balance @@ -1383,6 +1514,10 @@ class Opcodes(Opcode, Enum): - static_gas = 0 - dynamic_gas = 100 if warm_address, 2600 if cold_address + Metadata + ---- + - address_warm: whether the address is already warm (default: False) + Source: [evm.codes/#31](https://www.evm.codes/#31) """ @@ -1535,7 +1670,10 @@ class Opcodes(Opcode, Enum): """ CALLDATACOPY = Opcode( - 0x37, popped_stack_items=3, kwargs=["dest_offset", "offset", "size"] + 0x37, + popped_stack_items=3, + kwargs=["dest_offset", "offset", "size"], + metadata={"data_size": 0, "new_memory_size": 0, "old_memory_size": 0}, ) """ CALLDATACOPY(dest_offset, offset, size) @@ -1565,6 +1703,12 @@ class Opcodes(Opcode, Enum): - static_gas = 3 - dynamic_gas = 3 * minimum_word_size + memory_expansion_cost + Metadata + ---- + - data_size: number of bytes being copied (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#37](https://www.evm.codes/#37) """ @@ -1597,7 +1741,10 @@ class Opcodes(Opcode, Enum): """ CODECOPY = Opcode( - 0x39, popped_stack_items=3, kwargs=["dest_offset", "offset", "size"] + 0x39, + popped_stack_items=3, + kwargs=["dest_offset", "offset", "size"], + metadata={"data_size": 0, "new_memory_size": 0, "old_memory_size": 0}, ) """ CODECOPY(dest_offset, offset, size) @@ -1623,6 +1770,12 @@ class Opcodes(Opcode, Enum): - static_gas = 3 - dynamic_gas = 3 * minimum_word_size + memory_expansion_cost + Metadata + ---- + - data_size: number of bytes being copied (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#39](https://www.evm.codes/#39) """ @@ -1651,7 +1804,11 @@ class Opcodes(Opcode, Enum): """ EXTCODESIZE = Opcode( - 0x3B, popped_stack_items=1, pushed_stack_items=1, kwargs=["address"] + 0x3B, + popped_stack_items=1, + pushed_stack_items=1, + kwargs=["address"], + metadata={"address_warm": False}, ) """ EXTCODESIZE(address) = size @@ -1678,6 +1835,10 @@ class Opcodes(Opcode, Enum): - static_gas = 0 - dynamic_gas = 100 if warm_address, 2600 if cold_address + Metadata + ---- + - address_warm: whether the address is already warm (default: False) + Source: [evm.codes/#3B](https://www.evm.codes/#3B) """ @@ -1685,6 +1846,12 @@ class Opcodes(Opcode, Enum): 0x3C, popped_stack_items=4, kwargs=["address", "dest_offset", "offset", "size"], + metadata={ + "address_warm": False, + "data_size": 0, + "new_memory_size": 0, + "old_memory_size": 0, + }, ) """ EXTCODECOPY(address, dest_offset, offset, size) @@ -1716,6 +1883,13 @@ class Opcodes(Opcode, Enum): - dynamic_gas = 3 * minimum_word_size + memory_expansion_cost + address_access_cost + Metadata + ---- + - address_warm: whether the address is already warm (default: False) + - data_size: number of bytes being copied (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#3C](https://www.evm.codes/#3C) """ @@ -1744,7 +1918,10 @@ class Opcodes(Opcode, Enum): """ RETURNDATACOPY = Opcode( - 0x3E, popped_stack_items=3, kwargs=["dest_offset", "offset", "size"] + 0x3E, + popped_stack_items=3, + kwargs=["dest_offset", "offset", "size"], + metadata={"data_size": 0, "new_memory_size": 0, "old_memory_size": 0}, ) """ RETURNDATACOPY(dest_offset, offset, size) @@ -1771,11 +1948,21 @@ class Opcodes(Opcode, Enum): - static_gas = 3 - dynamic_gas = 3 * minimum_word_size + memory_expansion_cost + Metadata + ---- + - data_size: number of bytes being copied (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#3E](https://www.evm.codes/#3E) """ EXTCODEHASH = Opcode( - 0x3F, popped_stack_items=1, pushed_stack_items=1, kwargs=["address"] + 0x3F, + popped_stack_items=1, + pushed_stack_items=1, + kwargs=["address"], + metadata={"address_warm": False}, ) """ EXTCODEHASH(address) = hash @@ -1804,6 +1991,10 @@ class Opcodes(Opcode, Enum): - static_gas = 0 - dynamic_gas = 100 if warm_address, 2600 if cold_address + Metadata + ---- + - address_warm: whether the address is already warm (default: False) + Source: [evm.codes/#3F](https://www.evm.codes/#3F) """ @@ -2151,7 +2342,11 @@ class Opcodes(Opcode, Enum): """ MLOAD = Opcode( - 0x51, popped_stack_items=1, pushed_stack_items=1, kwargs=["offset"] + 0x51, + popped_stack_items=1, + pushed_stack_items=1, + kwargs=["offset"], + metadata={"new_memory_size": 0, "old_memory_size": 0}, ) """ MLOAD(offset) = value @@ -2179,10 +2374,20 @@ class Opcodes(Opcode, Enum): - static_gas = 3 - dynamic_gas = memory_expansion_cost + Metadata + ---- + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#51](https://www.evm.codes/#51) """ - MSTORE = Opcode(0x52, popped_stack_items=2, kwargs=["offset", "value"]) + MSTORE = Opcode( + 0x52, + popped_stack_items=2, + kwargs=["offset", "value"], + metadata={"new_memory_size": 0, "old_memory_size": 0}, + ) """ MSTORE(offset, value) ---- @@ -2209,10 +2414,20 @@ class Opcodes(Opcode, Enum): - static_gas = 3 - dynamic_gas = memory_expansion_cost + Metadata + ---- + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#52](https://www.evm.codes/#52) """ - MSTORE8 = Opcode(0x53, popped_stack_items=2, kwargs=["offset", "value"]) + MSTORE8 = Opcode( + 0x53, + popped_stack_items=2, + kwargs=["offset", "value"], + metadata={"new_memory_size": 0, "old_memory_size": 0}, + ) """ MSTORE8(offset, value) ---- @@ -2236,11 +2451,20 @@ class Opcodes(Opcode, Enum): - static_gas = 3 - dynamic_gas = memory_expansion_cost + Metadata + ---- + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#53](https://www.evm.codes/#53) """ SLOAD = Opcode( - 0x54, popped_stack_items=1, pushed_stack_items=1, kwargs=["key"] + 0x54, + popped_stack_items=1, + pushed_stack_items=1, + kwargs=["key"], + metadata={"key_warm": False}, ) """ SLOAD(key) = value @@ -2268,10 +2492,24 @@ class Opcodes(Opcode, Enum): - static_gas = 0 - dynamic_gas = 100 if warm_address, 2600 if cold_address + Metadata + ---- + - key_warm: whether the storage key is already warm (default: False) + Source: [evm.codes/#54](https://www.evm.codes/#54) """ - SSTORE = Opcode(0x55, popped_stack_items=2, kwargs=["key", "value"]) + SSTORE = Opcode( + 0x55, + popped_stack_items=2, + kwargs=["key", "value"], + metadata={ + "key_warm": False, + "original_value": 0, + "current_value": None, + "new_value": 1, + }, + ) """ SSTORE(key, value) ---- @@ -2315,6 +2553,16 @@ class Opcodes(Opcode, Enum): base_dynamic_gas += 2100 ``` + Metadata + ---- + - key_warm: whether the key had already been accessed during the + transaction, either by SLOAD or SSTORE (default: False) + - original_value: value the storage key had at the beginning of + the transaction (default: 0) + - current_value: value the storage key holds at the execution + of the opcode (default: None, which means same as original_value) + - new_value: value being set by the opcode (default: 1) + Source: [evm.codes/#55](https://www.evm.codes/#55) """ @@ -2571,7 +2819,10 @@ class Opcodes(Opcode, Enum): """ MCOPY = Opcode( - 0x5E, popped_stack_items=3, kwargs=["dest_offset", "offset", "size"] + 0x5E, + popped_stack_items=3, + kwargs=["dest_offset", "offset", "size"], + metadata={"data_size": 0, "new_memory_size": 0, "old_memory_size": 0}, ) """ MCOPY(dest_offset, offset, size) @@ -2601,6 +2852,12 @@ class Opcodes(Opcode, Enum): - static_gas = 3 - dynamic_gas = 3 * minimum_word_size + memory_expansion_cost + Metadata + ---- + - data_size: number of bytes being copied (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [EIP-5656](https://eips.ethereum.org/EIPS/eip-5656) """ @@ -4696,7 +4953,12 @@ class Opcodes(Opcode, Enum): Source: [evm.codes/#9F](https://www.evm.codes/#9F) """ - LOG0 = Opcode(0xA0, popped_stack_items=2, kwargs=["offset", "size"]) + LOG0 = Opcode( + 0xA0, + popped_stack_items=2, + kwargs=["offset", "size"], + metadata={"data_size": 0, "new_memory_size": 0, "old_memory_size": 0}, + ) """ LOG0(offset, size) ---- @@ -4723,11 +4985,20 @@ class Opcodes(Opcode, Enum): - static_gas = 375 - dynamic_gas = 375 * topic_count + 8 * size + memory_expansion_cost + Metadata + ---- + - data_size: number of bytes in the log data (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#A0](https://www.evm.codes/#A0) """ LOG1 = Opcode( - 0xA1, popped_stack_items=3, kwargs=["offset", "size", "topic_1"] + 0xA1, + popped_stack_items=3, + kwargs=["offset", "size", "topic_1"], + metadata={"data_size": 0, "new_memory_size": 0, "old_memory_size": 0}, ) """ LOG1(offset, size, topic_1) @@ -4756,6 +5027,12 @@ class Opcodes(Opcode, Enum): - static_gas = 375 - dynamic_gas = 375 * topic_count + 8 * size + memory_expansion_cost + Metadata + ---- + - data_size: number of bytes in the log data (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#A1](https://www.evm.codes/#A1) """ @@ -4763,6 +5040,7 @@ class Opcodes(Opcode, Enum): 0xA2, popped_stack_items=4, kwargs=["offset", "size", "topic_1", "topic_2"], + metadata={"data_size": 0, "new_memory_size": 0, "old_memory_size": 0}, ) """ LOG2(offset, size, topic_1, topic_2) @@ -4792,6 +5070,12 @@ class Opcodes(Opcode, Enum): - static_gas = 375 - dynamic_gas = 375 * topic_count + 8 * size + memory_expansion_cost + Metadata + ---- + - data_size: number of bytes in the log data (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#A2](https://www.evm.codes/#A2) """ @@ -4799,6 +5083,7 @@ class Opcodes(Opcode, Enum): 0xA3, popped_stack_items=5, kwargs=["offset", "size", "topic_1", "topic_2", "topic_3"], + metadata={"data_size": 0, "new_memory_size": 0, "old_memory_size": 0}, ) """ LOG3(offset, size, topic_1, topic_2, topic_3) @@ -4829,6 +5114,12 @@ class Opcodes(Opcode, Enum): - static_gas = 375 - dynamic_gas = 375 * topic_count + 8 * size + memory_expansion_cost + Metadata + ---- + - data_size: number of bytes in the log data (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#A3](https://www.evm.codes/#A3) """ @@ -4836,6 +5127,7 @@ class Opcodes(Opcode, Enum): 0xA4, popped_stack_items=6, kwargs=["offset", "size", "topic_1", "topic_2", "topic_3", "topic_4"], + metadata={"data_size": 0, "new_memory_size": 0, "old_memory_size": 0}, ) """ LOG4(offset, size, topic_1, topic_2, topic_3, topic_4) @@ -4867,6 +5159,12 @@ class Opcodes(Opcode, Enum): - static_gas = 375 - dynamic_gas = 375 * topic_count + 8 * size + memory_expansion_cost + Metadata + ---- + - data_size: number of bytes in the log data (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#A4](https://www.evm.codes/#A4) """ @@ -5407,6 +5705,11 @@ class Opcodes(Opcode, Enum): popped_stack_items=3, pushed_stack_items=1, kwargs=["value", "offset", "size"], + metadata={ + "init_code_size": 0, + "new_memory_size": 0, + "old_memory_size": 0, + }, ) """ CREATE(value, offset, size) = address @@ -5444,6 +5747,12 @@ class Opcodes(Opcode, Enum): code_deposit_cost ``` + Metadata + ---- + - init_code_size: size of the initialization code in bytes (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#F0](https://www.evm.codes/#F0) """ @@ -5461,6 +5770,15 @@ class Opcodes(Opcode, Enum): "ret_size", ], kwargs_defaults={"gas": GAS}, + metadata={ + "address_warm": False, + "value_transfer": False, + "account_new": False, + "new_memory_size": 0, + "old_memory_size": 0, + "delegated_address": False, + "delegated_address_warm": False, + }, ) """ CALL(gas, address, value, args_offset, args_size, ret_offset, ret_size) @@ -5501,6 +5819,18 @@ class Opcodes(Opcode, Enum): value_to_empty_account_cost ``` + Metadata + ---- + - address_warm: whether the address is already warm (default: False) + - value_transfer: whether value is being transferred (default: False) + - account_new: whether creating a new account (default: False) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + - delegated_address: whether the target is a delegated account + (default: False) + - delegated_address_warm: whether the delegated address of the target + is already warm (default: False) + Source: [evm.codes/#F1](https://www.evm.codes/#F1) """ @@ -5518,6 +5848,15 @@ class Opcodes(Opcode, Enum): "ret_size", ], kwargs_defaults={"gas": GAS}, + metadata={ + "address_warm": False, + "value_transfer": False, + "account_new": False, + "new_memory_size": 0, + "old_memory_size": 0, + "delegated_address": False, + "delegated_address_warm": False, + }, ) """ CALLCODE(gas, address, value, args_offset, args_size, ret_offset, ret_size) @@ -5558,11 +5897,31 @@ class Opcodes(Opcode, Enum): address_access_cost + positive_value_cost ``` + Metadata + ---- + - address_warm: whether the address is already warm (default: False) + - value_transfer: whether value is being transferred (default: False) + - account_new: whether creating a new account (default: False) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + - delegated_address: whether the target is a delegated account + (default: False) + - delegated_address_warm: whether the delegated address of the target + is already warm (default: False) + Source: [evm.codes/#F2](https://www.evm.codes/#F2) """ RETURN = Opcode( - 0xF3, popped_stack_items=2, kwargs=["offset", "size"], terminating=True + 0xF3, + popped_stack_items=2, + kwargs=["offset", "size"], + terminating=True, + metadata={ + "new_memory_size": 0, + "old_memory_size": 0, + "code_deposit_size": 0, + }, ) """ RETURN(offset, size) @@ -5591,6 +5950,13 @@ class Opcodes(Opcode, Enum): - static_gas = 0 - dynamic_gas = memory_expansion_cost + Metadata + ---- + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + - code_deposit_size: size of bytecode being deployed in bytes (default: 0, + only for RETURN in initcode) + Source: [evm.codes/#F3](https://www.evm.codes/#F3) """ @@ -5607,6 +5973,15 @@ class Opcodes(Opcode, Enum): "ret_size", ], kwargs_defaults={"gas": GAS}, + metadata={ + "address_warm": False, + "value_transfer": False, + "account_new": False, + "new_memory_size": 0, + "old_memory_size": 0, + "delegated_address": False, + "delegated_address_warm": False, + }, ) """ DELEGATECALL(gas, address, args_offset, args_size, ret_offset, ret_size) @@ -5644,6 +6019,18 @@ class Opcodes(Opcode, Enum): - dynamic_gas = memory_expansion_cost + code_execution_cost + address_access_cost + Metadata + ---- + - address_warm: whether the address is already warm (default: False) + - value_transfer: always False for DELEGATECALL (default: False) + - account_new: always False for DELEGATECALL (default: False) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + - delegated_address: whether the target is a delegated account + (default: False) + - delegated_address_warm: whether the delegated address of the target + is already warm (default: False) + Source: [evm.codes/#F4](https://www.evm.codes/#F4) """ @@ -5652,6 +6039,11 @@ class Opcodes(Opcode, Enum): popped_stack_items=4, pushed_stack_items=1, kwargs=["value", "offset", "size", "salt"], + metadata={ + "init_code_size": 0, + "new_memory_size": 0, + "old_memory_size": 0, + }, ) """ CREATE2(value, offset, size, salt) = address @@ -5691,6 +6083,12 @@ class Opcodes(Opcode, Enum): + deployment_code_execution_cost + code_deposit_cost ``` + Metadata + ---- + - init_code_size: size of the initialization code in bytes (default: 0) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#F5](https://www.evm.codes/#F5) """ @@ -5796,6 +6194,13 @@ class Opcodes(Opcode, Enum): "ret_size", ], kwargs_defaults={"gas": GAS}, + metadata={ + "address_warm": False, + "new_memory_size": 0, + "old_memory_size": 0, + "delegated_address": False, + "delegated_address_warm": False, + }, ) """ STATICCALL(gas, address, args_offset, args_size, ret_offset, ret_size) @@ -5832,6 +6237,18 @@ class Opcodes(Opcode, Enum): - dynamic_gas = memory_expansion_cost + code_execution_cost + address_access_cost + Metadata + ---- + - address_warm: whether the address is already warm (default: False) + - value_transfer: always False for STATICCALL (default: False) + - account_new: always False for STATICCALL (default: False) + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + - delegated_address: whether the target is a delegated account + (default: False) + - delegated_address_warm: whether the delegated address of the target + is already warm (default: False) + Source: [evm.codes/#FA](https://www.evm.codes/#FA) """ @@ -5903,7 +6320,11 @@ class Opcodes(Opcode, Enum): """ REVERT = Opcode( - 0xFD, popped_stack_items=2, kwargs=["offset", "size"], terminating=True + 0xFD, + popped_stack_items=2, + kwargs=["offset", "size"], + terminating=True, + metadata={"new_memory_size": 0, "old_memory_size": 0}, ) """ REVERT(offset, size) @@ -5928,6 +6349,11 @@ class Opcodes(Opcode, Enum): static_gas = 0 dynamic_gas = memory_expansion_cost + Metadata + ---- + - new_memory_size: memory size after expansion in bytes (default: 0) + - old_memory_size: memory size before expansion in bytes (default: 0) + Source: [evm.codes/#FD](https://www.evm.codes/#FD) """ @@ -5959,7 +6385,12 @@ class Opcodes(Opcode, Enum): Source: [evm.codes/#FE](https://www.evm.codes/#FE) """ - SELFDESTRUCT = Opcode(0xFF, popped_stack_items=1, kwargs=["address"]) + SELFDESTRUCT = Opcode( + 0xFF, + popped_stack_items=1, + kwargs=["address"], + metadata={"address_warm": False, "account_new": False}, + ) """ SELFDESTRUCT(address) ---- @@ -5980,6 +6411,13 @@ class Opcodes(Opcode, Enum): ---- 5000 + Metadata + ---- + - address_warm: whether the beneficiary address is already warm + (default: False) + - account_new: whether creating a new beneficiary account, requires + non-zero balance in the source account (default: False) + Source: [evm.codes/#FF](https://www.evm.codes/#FF) """ diff --git a/tests/berlin/eip2929_gas_cost_increases/test_call.py b/tests/berlin/eip2929_gas_cost_increases/test_call.py index a1b6238085..0ac8f376e6 100644 --- a/tests/berlin/eip2929_gas_cost_increases/test_call.py +++ b/tests/berlin/eip2929_gas_cost_increases/test_call.py @@ -24,8 +24,8 @@ def test_call_insufficient_balance( Test a regular CALL to see if it warms the destination with insufficient balance. """ - gas_costs = fork.gas_costs() destination = pre.fund_eoa(1) + warm_code = Op.BALANCE(destination, address_warm=True) contract_address = pre.deploy_contract( # Perform the aborted external calls Op.SSTORE( @@ -42,8 +42,7 @@ def test_call_insufficient_balance( ) # Measure the gas cost for BALANCE operation + CodeGasMeasure( - code=Op.BALANCE(destination), - overhead_cost=gas_costs.G_VERY_LOW, # PUSH20 costs 3 gas + code=warm_code, extra_stack_items=1, # BALANCE puts balance on stack sstore_key=1, ), @@ -63,7 +62,7 @@ def test_call_insufficient_balance( contract_address: Account( storage={ 0: 0, # The CALL is aborted - 1: gas_costs.G_WARM_ACCOUNT_ACCESS, # Warm access cost + 1: warm_code.gas_cost(fork), }, ), } diff --git a/tests/cancun/eip5656_mcopy/test_mcopy_memory_expansion.py b/tests/cancun/eip5656_mcopy/test_mcopy_memory_expansion.py index 9b12efb34d..0d49e55766 100644 --- a/tests/cancun/eip5656_mcopy/test_mcopy_memory_expansion.py +++ b/tests/cancun/eip5656_mcopy/test_mcopy_memory_expansion.py @@ -39,12 +39,21 @@ @pytest.fixture -def callee_bytecode(dest: int, src: int, length: int) -> Bytecode: +def callee_bytecode( + dest: int, src: int, length: int, initial_memory: bytes +) -> Bytecode: """Callee performs a single mcopy operation and then returns.""" bytecode = Bytecode() # Copy the initial memory - bytecode += Op.CALLDATACOPY(0x00, 0x00, Op.CALLDATASIZE()) + bytecode += Op.CALLDATACOPY( + dest_offset=0x00, + offset=0x00, + size=Op.CALLDATASIZE, + old_memory_size=0, + new_memory_size=len(initial_memory), + data_size=len(initial_memory), + ) # Pushes for the return operation bytecode += Op.PUSH1(0x00) + Op.PUSH1(0x00) @@ -52,7 +61,17 @@ def callee_bytecode(dest: int, src: int, length: int) -> Bytecode: bytecode += Op.SSTORE(slot_code_worked, value_code_worked) # Perform the mcopy operation - bytecode += Op.MCOPY(dest, src, length) + new_memory_size = len(initial_memory) + if dest + length > new_memory_size and length > 0: + new_memory_size = dest + length + bytecode += Op.MCOPY( + dest, + src, + length, + old_memory_size=len(initial_memory), + new_memory_size=new_memory_size, + data_size=length, + ) bytecode += Op.RETURN @@ -68,79 +87,35 @@ def tx_access_list() -> List[AccessList]: @pytest.fixture -def call_exact_cost( - fork: Fork, - initial_memory: bytes, - dest: int, - length: int, - tx_access_list: List[AccessList], -) -> int: - """ - Return the exact cost of the subcall, based on the initial memory and the - length of the copy. - """ - # Starting from EIP-7623, we need to use an access list to raise the - # intrinsic gas cost to be above the floor data cost. - cost_memory_bytes = fork.memory_expansion_gas_calculator() - gas_costs = fork.gas_costs() - tx_intrinsic_gas_cost_calculator = ( - fork.transaction_intrinsic_cost_calculator() - ) - - mcopy_cost = 3 - mcopy_cost += 3 * ((length + 31) // 32) - if length > 0 and dest + length > len(initial_memory): - mcopy_cost += cost_memory_bytes( - new_bytes=dest + length, previous_bytes=len(initial_memory) - ) - - calldatacopy_cost = 3 - calldatacopy_cost += 3 * ((len(initial_memory) + 31) // 32) - calldatacopy_cost += cost_memory_bytes(new_bytes=len(initial_memory)) - - pushes_cost = gas_costs.G_VERY_LOW * 9 - calldatasize_cost = gas_costs.G_BASE - - sstore_cost = 22100 - return ( - tx_intrinsic_gas_cost_calculator( - calldata=initial_memory, access_list=tx_access_list - ) - + mcopy_cost - + calldatacopy_cost - + pushes_cost - + calldatasize_cost - + sstore_cost - ) - - -@pytest.fixture -def block_gas_limit() -> int: # noqa: D103 - return 100_000_000 +def block_gas_limit(env: Environment) -> int: # noqa: D103 + return env.gas_limit @pytest.fixture def tx_gas_limit( # noqa: D103 fork: Fork, - call_exact_cost: int, + callee_bytecode: Bytecode, block_gas_limit: int, successful: bool, + initial_memory: bytes, + tx_access_list: List[AccessList], ) -> int: + tx_intrinsic_gas_cost_calculator = ( + fork.transaction_intrinsic_cost_calculator() + ) + call_exact_cost = callee_bytecode.gas_cost(fork) return min( - call_exact_cost - (0 if successful else 1), + call_exact_cost + - (0 if successful else 1) + + tx_intrinsic_gas_cost_calculator( + calldata=initial_memory, access_list=tx_access_list + ), # If the transaction gas limit cap is not set (pre-osaka), # use the block gas limit fork.transaction_gas_limit_cap() or block_gas_limit, ) -@pytest.fixture -def env( # noqa: D103 - block_gas_limit: int, -) -> Environment: - return Environment(gas_limit=block_gas_limit) - - @pytest.fixture def caller_address(pre: Alloc, callee_bytecode: bytes) -> Address: # noqa: D103 return pre.deploy_contract(code=callee_bytecode) @@ -261,11 +236,6 @@ def test_mcopy_memory_expansion( "half_max_length_expansion", ], ) -@pytest.mark.parametrize( - "call_exact_cost", - [2**128 - 1], - ids=[""], -) # Limit subcall gas, otherwise it would be impossibly large @pytest.mark.parametrize("successful", [False]) @pytest.mark.parametrize( "initial_memory", diff --git a/tests/frontier/create/test_create_deposit_oog.py b/tests/frontier/create/test_create_deposit_oog.py index 085c15a632..18932d3ca3 100644 --- a/tests/frontier/create/test_create_deposit_oog.py +++ b/tests/frontier/create/test_create_deposit_oog.py @@ -6,70 +6,100 @@ from execution_testing import ( Account, Alloc, - Environment, Fork, Op, StateTestFiller, - Storage, Transaction, compute_create_address, ) -from execution_testing.forks import Byzantium, Frontier, Homestead +from execution_testing.forks import Byzantium, Frontier SLOT_CREATE_RESULT = 1 SLOT_CREATE_RESULT_PRE = 0xDEADBEEF @pytest.mark.valid_from("Frontier") +@pytest.mark.parametrize("enough_gas", [True, False]) @pytest.mark.with_all_create_opcodes def test_create_deposit_oog( state_test: StateTestFiller, fork: Fork, pre: Alloc, create_opcode: Op, + enough_gas: bool, ) -> None: """Run create deploys with a lot of deposited code.""" - deposited_len = 10_000 - initcode = Op.RETURN(0, deposited_len) - tx_gas_limit = 1_000_000 - assert tx_gas_limit < deposited_len * fork.gas_costs().G_CODE_DEPOSIT_BYTE + deposited_len = 32 + expand_memory_code = Op.MSTORE8( + # Expand memory first + offset=deposited_len - 1, + value=0, + new_memory_size=deposited_len, # For gas accounting + ) + return_code = Op.RETURN( + offset=0, + size=deposited_len, + code_deposit_size=deposited_len, # For gas accounting + ) + initcode = expand_memory_code + return_code sender = pre.fund_eoa() - expect_post = Storage() - code = pre.deploy_contract( - code=Op.MSTORE(0, Op.PUSH32(bytes(initcode))) - + Op.SSTORE( - SLOT_CREATE_RESULT, - create_opcode(offset=32 - len(initcode), size=len(initcode)), + factory_memory_expansion_code = Op.MSTORE( + 0, + Op.PUSH32(bytes(initcode)), + new_memory_size=32, # For gas accounting + ) + factory_create_code = create_opcode( + offset=32 - len(initcode), + size=len(initcode), + init_code_size=len(initcode), # For gas accounting + ) + factory_code = ( + factory_memory_expansion_code + factory_create_code + Op.STOP + ) + + factory_address = pre.deploy_contract(code=factory_code) + create_gas = return_code.gas_cost(fork) + expand_memory_code.gas_cost(fork) + if not enough_gas: + create_gas -= 1 + if fork >= Byzantium: + # Increment the gas for the 63/64 rule + create_gas = (create_gas * 64) // 63 + call_gas = create_gas + factory_code.gas_cost(fork) + caller_address = pre.deploy_contract( + code=Op.CALL( + gas=call_gas, address=factory_address, ret_offset=0, ret_size=32 ) + Op.STOP, - nonce=1, - storage={SLOT_CREATE_RESULT: SLOT_CREATE_RESULT_PRE}, ) new_address = compute_create_address( - address=code, nonce=1, initcode=initcode, salt=0, opcode=create_opcode + address=factory_address, + nonce=1, + initcode=initcode, + salt=0, + opcode=create_opcode, ) - if fork == Frontier: - expect_post[SLOT_CREATE_RESULT] = new_address - elif fork == Homestead: - # Before the introduction of the 63/64th rule there is no - # gas left for SSTOREing the return value. - expect_post[SLOT_CREATE_RESULT] = SLOT_CREATE_RESULT_PRE - else: - expect_post[SLOT_CREATE_RESULT] = 0 - tx = Transaction( - gas_limit=tx_gas_limit, - to=code, + gas_limit=10_000_000, + to=caller_address, sender=sender, protected=fork >= Byzantium, ) + created_account: Account | None = Account(code=b"\x00" * deposited_len) + if not enough_gas: + if fork > Frontier: + created_account = None + else: + # At Frontier, OOG on return yields an empty account. + created_account = Account() + post = { - code: Account(storage=expect_post), - new_address: Account(code=b"", nonce=0) if fork == Frontier else None, + factory_address: Account(nonce=2), + caller_address: Account(nonce=1), + new_address: created_account, } - state_test(env=Environment(), pre=pre, post=post, tx=tx) + state_test(pre=pre, post=post, tx=tx) diff --git a/tests/frontier/opcodes/test_all_opcodes.py b/tests/frontier/opcodes/test_all_opcodes.py index 1e1ec957e7..022a1f4968 100644 --- a/tests/frontier/opcodes/test_all_opcodes.py +++ b/tests/frontier/opcodes/test_all_opcodes.py @@ -214,139 +214,26 @@ def constant_gas_opcodes(fork: Fork) -> Generator[ParameterSet, None, None]: per fork. """ valid_opcodes = set(fork.valid_opcodes()) - gas_costs = fork.gas_costs() - opcode_floor_gas = { - Op.ADD: gas_costs.G_VERY_LOW, - Op.MUL: gas_costs.G_LOW, - Op.SUB: gas_costs.G_VERY_LOW, - Op.DIV: gas_costs.G_LOW, - Op.SDIV: gas_costs.G_LOW, - Op.MOD: gas_costs.G_LOW, - Op.SMOD: gas_costs.G_LOW, - Op.ADDMOD: gas_costs.G_MID, - Op.MULMOD: gas_costs.G_MID, - Op.EXP: gas_costs.G_HIGH, - Op.SIGNEXTEND: gas_costs.G_LOW, - Op.LT: gas_costs.G_VERY_LOW, - Op.GT: gas_costs.G_VERY_LOW, - Op.SLT: gas_costs.G_VERY_LOW, - Op.SGT: gas_costs.G_VERY_LOW, - Op.EQ: gas_costs.G_VERY_LOW, - Op.ISZERO: gas_costs.G_VERY_LOW, - Op.AND: gas_costs.G_VERY_LOW, - Op.OR: gas_costs.G_VERY_LOW, - Op.XOR: gas_costs.G_VERY_LOW, - Op.NOT: gas_costs.G_VERY_LOW, - Op.BYTE: gas_costs.G_VERY_LOW, - Op.SHL: gas_costs.G_VERY_LOW, - Op.SHR: gas_costs.G_VERY_LOW, - Op.SAR: gas_costs.G_VERY_LOW, - Op.CLZ: gas_costs.G_LOW, - Op.SHA3: gas_costs.G_KECCAK_256, - Op.ADDRESS: gas_costs.G_BASE, - Op.BALANCE: gas_costs.G_WARM_ACCOUNT_ACCESS, - Op.ORIGIN: gas_costs.G_BASE, - Op.CALLER: gas_costs.G_BASE, - Op.CALLVALUE: gas_costs.G_BASE, - Op.CALLDATALOAD: gas_costs.G_VERY_LOW, - Op.CALLDATASIZE: gas_costs.G_BASE, - Op.CALLDATACOPY: gas_costs.G_COPY, - Op.CODESIZE: gas_costs.G_BASE, - Op.CODECOPY: gas_costs.G_COPY, - Op.GASPRICE: gas_costs.G_BASE, - Op.EXTCODESIZE: gas_costs.G_WARM_ACCOUNT_ACCESS, - Op.EXTCODECOPY: gas_costs.G_WARM_ACCOUNT_ACCESS, - Op.RETURNDATASIZE: gas_costs.G_BASE, - Op.RETURNDATACOPY: gas_costs.G_COPY, - Op.EXTCODEHASH: gas_costs.G_WARM_ACCOUNT_ACCESS, - Op.BLOCKHASH: gas_costs.G_BLOCKHASH, - Op.COINBASE: gas_costs.G_BASE, - Op.TIMESTAMP: gas_costs.G_BASE, - Op.NUMBER: gas_costs.G_BASE, - Op.PREVRANDAO: gas_costs.G_BASE, - Op.GASLIMIT: gas_costs.G_BASE, - Op.CHAINID: gas_costs.G_BASE, - Op.SELFBALANCE: gas_costs.G_LOW, - Op.BASEFEE: gas_costs.G_BASE, - Op.BLOBHASH: gas_costs.G_VERY_LOW, - Op.BLOBBASEFEE: gas_costs.G_BASE, - Op.POP: gas_costs.G_BASE, - Op.MLOAD: gas_costs.G_VERY_LOW, - Op.MSTORE: gas_costs.G_VERY_LOW, - Op.MSTORE8: gas_costs.G_VERY_LOW, - Op.SLOAD: gas_costs.G_WARM_SLOAD, - Op.JUMP: gas_costs.G_MID, - Op.JUMPI: gas_costs.G_HIGH, - Op.PC: gas_costs.G_BASE, - Op.MSIZE: gas_costs.G_BASE, - Op.GAS: gas_costs.G_BASE, - Op.JUMPDEST: gas_costs.G_JUMPDEST, - Op.TLOAD: gas_costs.G_WARM_SLOAD, - Op.TSTORE: gas_costs.G_WARM_SLOAD, - Op.MCOPY: gas_costs.G_VERY_LOW, - Op.PUSH0: gas_costs.G_BASE, - Op.LOG0: gas_costs.G_LOG + (0 * gas_costs.G_LOG_TOPIC), - Op.LOG1: gas_costs.G_LOG + (1 * gas_costs.G_LOG_TOPIC), - Op.LOG2: gas_costs.G_LOG + (2 * gas_costs.G_LOG_TOPIC), - Op.LOG3: gas_costs.G_LOG + (3 * gas_costs.G_LOG_TOPIC), - Op.LOG4: gas_costs.G_LOG + (4 * gas_costs.G_LOG_TOPIC), - Op.CREATE: gas_costs.G_TRANSACTION_CREATE, - Op.CALL: gas_costs.G_WARM_ACCOUNT_ACCESS, - Op.CALLCODE: gas_costs.G_WARM_ACCOUNT_ACCESS, - Op.DELEGATECALL: gas_costs.G_WARM_ACCOUNT_ACCESS, - Op.CREATE2: gas_costs.G_TRANSACTION_CREATE, - Op.STATICCALL: gas_costs.G_WARM_ACCOUNT_ACCESS, - Op.SELFDESTRUCT: gas_costs.G_SELF_DESTRUCT, - Op.STOP: 0, - Op.RETURN: 0, - Op.REVERT: 0, - Op.INVALID: 0, - } - - # PUSHx, SWAPx, DUPx have uniform gas costs - for opcode in valid_opcodes: - if 0x60 <= opcode.int() <= 0x9F: - opcode_floor_gas[opcode] = gas_costs.G_VERY_LOW - for opcode in sorted(valid_opcodes): # SSTORE - untestable due to 2300 gas stipend rule if opcode == Op.SSTORE: continue - warm_gas = opcode_floor_gas[opcode] - if warm_gas == 0: + if opcode.gas_cost(fork) == 0: # zero constant gas opcodes - untestable continue - cold_gas = warm_gas - if opcode in [ - Op.BALANCE, - Op.EXTCODESIZE, - Op.EXTCODECOPY, - Op.EXTCODEHASH, - Op.CALL, - Op.CALLCODE, - Op.DELEGATECALL, - Op.STATICCALL, - ]: - cold_gas = gas_costs.G_COLD_ACCOUNT_ACCESS - elif opcode == Op.SELFDESTRUCT: - # Add the cost of accessing the send all destination account. - cold_gas += gas_costs.G_COLD_ACCOUNT_ACCESS - elif opcode == Op.SLOAD: - cold_gas = gas_costs.G_COLD_SLOAD - yield pytest.param(opcode, warm_gas, cold_gas, id=f"{opcode}") + yield pytest.param( + opcode, + id=f"{opcode}", + ) @pytest.mark.valid_from("Berlin") -@pytest.mark.parametrize_by_fork( - "opcode,warm_gas,cold_gas", constant_gas_opcodes -) +@pytest.mark.parametrize_by_fork("opcode", constant_gas_opcodes) def test_constant_gas( state_test: StateTestFiller, pre: Alloc, - opcode: Op, + opcode: Opcode, fork: Fork, - warm_gas: int, - cold_gas: int, ) -> None: """Test that constant gas opcodes work as expected.""" # Using Op.GAS as salt to guarantee no address collision on CREATE2. @@ -357,13 +244,21 @@ def test_constant_gas( + prepare_stack_constant_gas_oog(opcode) + create2_salt ) + warm_opcode_metadata = {} + if "address_warm" in opcode.metadata: + warm_opcode_metadata["address_warm"] = True + if "key_warm" in opcode.metadata: + warm_opcode_metadata["key_warm"] = True + if warm_opcode_metadata: + warm_opcode = opcode(**warm_opcode_metadata) + else: + warm_opcode = opcode gas_test( fork=fork, state_test=state_test, pre=pre, setup_code=setup_code, subject_code=opcode, + subject_code_warm=warm_opcode, tear_down_code=prepare_suffix(opcode), - cold_gas=cold_gas, - warm_gas=warm_gas, ) diff --git a/tests/frontier/opcodes/test_dup.py b/tests/frontier/opcodes/test_dup.py index 6ff2e859e7..35fd3c428f 100644 --- a/tests/frontier/opcodes/test_dup.py +++ b/tests/frontier/opcodes/test_dup.py @@ -69,12 +69,9 @@ def test_dup( tx = Transaction( ty=0x0, - nonce=0, to=account, gas_limit=500000, - gas_price=10, protected=False if fork in [Frontier, Homestead] else True, - data="", sender=sender, ) diff --git a/tests/frontier/opcodes/test_exp.py b/tests/frontier/opcodes/test_exp.py index bf4851cdd8..2fcbbe9f99 100644 --- a/tests/frontier/opcodes/test_exp.py +++ b/tests/frontier/opcodes/test_exp.py @@ -15,13 +15,6 @@ REFERENCE_SPEC_VERSION = "N/A" -def exp_gas(fork: Fork, exponent: int) -> int: - """Calculate gas cost for EXP opcode given the exponent.""" - gas_costs = fork.gas_costs() - byte_len = (exponent.bit_length() + 7) // 8 - return gas_costs.G_EXP + gas_costs.G_EXP_BYTE * byte_len - - @pytest.mark.valid_from("Berlin") @pytest.mark.parametrize( "a", [0, 1, pytest.param(2**256 - 1, id="a2to256minus1")] @@ -46,14 +39,10 @@ def test_gas( fork: Fork, ) -> None: """Test that EXP gas works as expected.""" - gas_cost = exp_gas(fork, exponent) - gas_test( fork=fork, state_test=state_test, pre=pre, setup_code=Op.PUSH32(exponent) + Op.PUSH32(a), - subject_code=Op.EXP, - cold_gas=gas_cost, - warm_gas=gas_cost, + subject_code=Op.EXP(exponent=exponent), ) diff --git a/tests/frontier/opcodes/test_log.py b/tests/frontier/opcodes/test_log.py index bb8fc25070..e3cfe61c4f 100644 --- a/tests/frontier/opcodes/test_log.py +++ b/tests/frontier/opcodes/test_log.py @@ -15,19 +15,6 @@ REFERENCE_SPEC_VERSION = "N/A" -def log_gas(fork: Fork, topics: int, data_size: int) -> int: - """ - Calculate gas cost for LOGx opcodes given the number of topics and data - size. - """ - gas_costs = fork.gas_costs() - return ( - gas_costs.G_LOG - + gas_costs.G_LOG_TOPIC * topics - + gas_costs.G_LOG_DATA * data_size - ) - - @pytest.mark.valid_from("Berlin") @pytest.mark.parametrize( "opcode,topics", @@ -52,8 +39,6 @@ def test_gas( fork: Fork, ) -> None: """Test that LOGx gas works as expected.""" - gas_cost = log_gas(fork, topics, data_size) - gas_test( fork=fork, state_test=state_test, @@ -62,7 +47,5 @@ def test_gas( + Op.PUSH1(0) * topics + Op.PUSH32(data_size) + Op.PUSH1(0), - subject_code=opcode, - cold_gas=gas_cost, - warm_gas=gas_cost, + subject_code=opcode(data_size=data_size), ) diff --git a/tests/osaka/eip7939_count_leading_zeros/test_count_leading_zeros.py b/tests/osaka/eip7939_count_leading_zeros/test_count_leading_zeros.py index ff1da2f5ee..ec4e7ac17f 100644 --- a/tests/osaka/eip7939_count_leading_zeros/test_count_leading_zeros.py +++ b/tests/osaka/eip7939_count_leading_zeros/test_count_leading_zeros.py @@ -136,7 +136,7 @@ def test_clz_gas_cost( CodeGasMeasure( code=Op.CLZ(Op.PUSH1(1)), extra_stack_items=1, - overhead_cost=fork.gas_costs().G_VERY_LOW, + overhead_cost=Op.PUSH1.gas_cost(fork), ), ), storage={"0x00": "0xdeadbeef"}, @@ -145,7 +145,7 @@ def test_clz_gas_cost( tx = Transaction(to=contract_address, sender=sender, gas_limit=200_000) post = { contract_address: Account( # Cost measured is CLZ + PUSH1 - storage={"0x00": fork.gas_costs().G_LOW} + storage={"0x00": Op.CLZ.gas_cost(fork)} ), } state_test(pre=pre, post=post, tx=tx) @@ -172,9 +172,7 @@ def test_clz_gas_cost_boundary( call_code = Op.SSTORE( 0, Op.CALL( - gas=fork.gas_costs().G_VERY_LOW - + Spec.CLZ_GAS_COST - + gas_cost_delta, + gas=code.gas_cost(fork) + gas_cost_delta, address=contract_address, ), )