Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 23 additions & 31 deletions src/ethereum_test_benchmark/benchmark_code_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,42 @@
optimized bytecode patterns.
"""

from dataclasses import dataclass

from ethereum_test_base_types import Address
from ethereum_test_forks import Fork
from ethereum_test_specs.benchmark import BenchmarkCodeGenerator
from ethereum_test_types import Alloc, Transaction
from ethereum_test_vm import Bytecode
from ethereum_test_types import Alloc
from ethereum_test_vm.opcodes import Opcodes as Op


@dataclass(kw_only=True)
class JumpLoopGenerator(BenchmarkCodeGenerator):
"""Generates bytecode that loops execution using JUMP operations."""

def deploy_contracts(self, pre: Alloc, fork: Fork) -> None:
def deploy_contracts(self, *, pre: Alloc, fork: Fork) -> Address:
"""Deploy the looping contract."""
# Benchmark Test Structure:
# setup + JUMPDEST + attack + attack + ... +
# attack + JUMP(setup_length)
code = self.generate_repeated_code(self.attack_block, self.setup, fork)
self._contract_address = pre.deploy_contract(code=code)

def generate_transaction(self, pre: Alloc, gas_limit: int, fork: Fork) -> Transaction:
"""Generate transaction that executes the looping contract."""
if not hasattr(self, "_contract_address"):
raise ValueError("deploy_contracts must be called before generate_transaction")

return Transaction(
to=self._contract_address,
gas_limit=gas_limit,
sender=pre.fund_eoa(),
# setup + JUMPDEST +
# attack + attack + ... + attack +
# cleanup + JUMP(setup_length)
code = self.generate_repeated_code(
repeated_code=self.attack_block, setup=self.setup, cleanup=self.cleanup, fork=fork
)
self._contract_address = pre.deploy_contract(code=code)
return self._contract_address


@dataclass(kw_only=True)
class ExtCallGenerator(BenchmarkCodeGenerator):
"""
Generates bytecode that fills the contract to
maximum allowed code size.
"""

def deploy_contracts(self, pre: Alloc, fork: Fork) -> None:
contract_balance: int = 0

def deploy_contracts(self, *, pre: Alloc, fork: Fork) -> Address:
"""Deploy both target and caller contracts."""
# Benchmark Test Structure:
# There are two contracts:
Expand All @@ -53,7 +52,8 @@ def deploy_contracts(self, pre: Alloc, fork: Fork) -> None:

# Deploy target contract that contains the actual attack block
self._target_contract_address = pre.deploy_contract(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My comment is actually on the max_iterations line: I think the fork.max_stack_height part of the min only holds if the attack_block pushes exactly one item to the stack, if it pushes zero items to the stack we can keep going, and if pushes more than one item to the stack we will overflow it.

Bytecode actually has properties that could help make this more fail-safe and are automatically calculated:

popped_stack_items: int
pushed_stack_items: int
max_stack_height: int
min_stack_height: int

code=self.attack_block * max_iterations
code=self.setup + self.attack_block * max_iterations,
balance=self.contract_balance,
)

# Create caller contract that repeatedly calls the target contract
Expand All @@ -65,16 +65,8 @@ def deploy_contracts(self, pre: Alloc, fork: Fork) -> None:
# JUMP(setup_length)
code_sequence = Op.POP(Op.STATICCALL(Op.GAS, self._target_contract_address, 0, 0, 0, 0))

caller_code = self.generate_repeated_code(code_sequence, Bytecode(), fork)
self._contract_address = pre.deploy_contract(code=caller_code)

def generate_transaction(self, pre: Alloc, gas_limit: int, fork: Fork) -> Transaction:
"""Generate transaction that executes the caller contract."""
if not hasattr(self, "_contract_address"):
raise ValueError("deploy_contracts must be called before generate_transaction")

return Transaction(
to=self._contract_address,
gas_limit=gas_limit,
sender=pre.fund_eoa(),
caller_code = self.generate_repeated_code(
repeated_code=code_sequence, cleanup=self.cleanup, fork=fork
)
self._contract_address = pre.deploy_contract(code=caller_code)
return self._contract_address
58 changes: 44 additions & 14 deletions src/ethereum_test_specs/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Callable, ClassVar, Dict, Generator, List, Sequence, Type
from typing import Any, Callable, ClassVar, Dict, Generator, List, Sequence, Type

import pytest
from pydantic import ConfigDict, Field

from ethereum_clis import TransitionTool
from ethereum_test_base_types import HexNumber
from ethereum_test_base_types import Address, HexNumber
from ethereum_test_exceptions import BlockException, TransactionException
from ethereum_test_execution import (
BaseExecute,
Expand Down Expand Up @@ -40,32 +40,52 @@ class BenchmarkCodeGenerator(ABC):

attack_block: Bytecode
setup: Bytecode = field(default_factory=Bytecode)
cleanup: Bytecode = field(default_factory=Bytecode)
tx_kwargs: Dict[str, Any] = field(default_factory=dict)
_contract_address: Address | None = None

@abstractmethod
def deploy_contracts(self, pre: Alloc, fork: Fork) -> None:
def deploy_contracts(self, *, pre: Alloc, fork: Fork) -> Address:
"""Deploy any contracts needed for the benchmark."""
...

@abstractmethod
def generate_transaction(self, pre: Alloc, gas_limit: int, fork: Fork) -> Transaction:
"""Generate a transaction with the specified gas limit."""
...
def generate_transaction(self, *, pre: Alloc, gas_benchmark_value: int) -> Transaction:
"""Generate transaction that executes the looping contract."""
assert self._contract_address is not None
if "gas_limit" not in self.tx_kwargs:
self.tx_kwargs["gas_limit"] = gas_benchmark_value

return Transaction(
to=self._contract_address,
sender=pre.fund_eoa(),
**self.tx_kwargs,
)

def generate_repeated_code(
self, repeated_code: Bytecode, setup: Bytecode, fork: Fork
self,
*,
repeated_code: Bytecode,
setup: Bytecode | None = None,
cleanup: Bytecode | None = None,
fork: Fork,
) -> Bytecode:
"""
Calculate the maximum number of iterations that
can fit in the code size limit.
"""
assert len(repeated_code) > 0, "repeated_code cannot be empty"
max_code_size = fork.max_code_size()

overhead = len(setup) + len(Op.JUMPDEST) + len(Op.JUMP(len(setup)))
if setup is None:
setup = Bytecode()
if cleanup is None:
cleanup = Bytecode()
overhead = len(setup) + len(Op.JUMPDEST) + len(cleanup) + len(Op.JUMP(len(setup)))
available_space = max_code_size - overhead
max_iterations = available_space // len(repeated_code)

code = setup + Op.JUMPDEST + repeated_code * max_iterations + Op.JUMP(len(setup))
# TODO: Unify the PUSH0 and PUSH1 usage.
code = setup + Op.JUMPDEST + repeated_code * max_iterations + cleanup
code += Op.JUMP(len(setup)) if len(setup) > 0 else Op.PUSH0 + Op.JUMP
self._validate_code_size(code, fork)

return code
Expand All @@ -84,7 +104,7 @@ class BenchmarkTest(BaseTest):

model_config = ConfigDict(extra="forbid")

pre: Alloc
pre: Alloc = Field(default_factory=Alloc)
post: Alloc = Field(default_factory=Alloc)
tx: Transaction | None = None
blocks: List[Block] | None = None
Expand Down Expand Up @@ -115,6 +135,14 @@ class BenchmarkTest(BaseTest):
"blockchain_test_only": "Only generate a blockchain test fixture",
}

def model_post_init(self, __context: Any, /) -> None:
"""
Model post-init to assert that the custom pre-allocation was
provided and the default was not used.
"""
super().model_post_init(__context)
assert "pre" in self.model_fields_set, "pre allocation was not provided"

@classmethod
def pytest_parameter_name(cls) -> str:
"""
Expand Down Expand Up @@ -175,9 +203,11 @@ def generate_blocks_from_code_generator(self, fork: Fork) -> List[Block]:
if self.code_generator is None:
raise Exception("Code generator is not set")

self.code_generator.deploy_contracts(self.pre, fork)
self.code_generator.deploy_contracts(pre=self.pre, fork=fork)
gas_limit = fork.transaction_gas_limit_cap() or self.gas_benchmark_value
benchmark_tx = self.code_generator.generate_transaction(self.pre, gas_limit, fork)
benchmark_tx = self.code_generator.generate_transaction(
pre=self.pre, gas_benchmark_value=gas_limit
)

execution_txs = self.split_transaction(benchmark_tx, gas_limit)
execution_block = Block(txs=execution_txs)
Expand Down
Loading