ethereum / ethereum/execution-specs
refactor(test-specs): Replace `BaseExecute` with mixins on `BaseTest`
- Dominant language
- Python
- Stars
- 1.2k
- Forks
- 505
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 116
Description
## Problem
The current execution architecture has an unnecessary level of indirection. When a test needs to be executed against a live network, the flow is:
1. A BaseTest subclass (e.g. StateTest, BlockchainTest) builds its test data
2. Its execute() method constructs a separate BaseExecute subclass instance (e.g. TransactionPost) by copying data into it
3. The pytest plugin calls BaseExecute.get_required_sender_balances() and BaseExecute.execute() on that separate object
This means every BaseTest.execute() override is just a factory that copies fields into a BaseExecute data class:
### StateTest.execute()
```python
return TransactionPost(blocks=[[self.tx]], post=self.post, benchmark_mode=...)
```
### BlockchainTest.execute()
```python
blocks = [block.txs for block in self.blocks]
return TransactionPost(blocks=blocks, post=self.post, benchmark_mode=...)
```
### BlobsTest.execute()
```python
return BlobTransaction(txs=self.txs, nonexisting_blob_hashes=...)
```
The BaseExecute subclasses don't add behavior beyond what they receive — they're just intermediate containers that re-package data the BaseTest already owns.
## Proposed design
Remove BaseExecute as a base class and instead make get_required_sender_balances() and execute() (the network-execution method) abstract methods directly on BaseTest. Turn the current BaseExecute subclasses (TransactionPost, BlobTransaction) into mixins that provide concrete implementations of those abstract methods.
```
CURRENT
═══════
┌──────────────────┐ execute() ┌──────────────────┐
│ StateTest │──────────────►│ TransactionPost │
│ (BaseTest) │ returns new │ (BaseExecute) │
│ │ instance │ │
│ - tx │ │ - blocks │ ◄── data copied
│ - post │ │ - post │ from StateTest
└──────────────────┘ │ │
│ .execute() │──► sends txs to net
│ .get_required_ │
│ sender_balances │
└──────────────────┘
PROPOSED
════════
┌─────────────────────────────────────────────┐
│ StateTest │
│ (BaseTest + TransactionPostMixin) │
│ │
│ - tx, post (owned data) │
│ │
│ # From TransactionPostMixin: │
│ .get_required_sender_balances() │──► iterates over
│ .execute_test() │ transaction_post_blocks()
│ │
│ # Implemented by StateTest: │
│ .transaction_post_blocks() -> [[self.tx]] │──► bridge method
│ .transaction_post_alloc() -> self.post │
└─────────────────────────────────────────────┘
```
## Detailed changes
1. BaseTest (specs/base.py)
Add two abstract methods:
- `get_required_sender_balances(*, gas_price, max_fee_per_gas, max_priority_fee_per_gas, max_fee_per_blob_gas, fork) -> Dict[Address, int]`
- `execute_test(fork, eth_rpc, engine_rpc, request, execute_format) -> ExecuteResult`
Remove:
- The current `execute(*, execute_format) -> BaseExecute` factory method
2. `TransactionPost` → `TransactionPostMixin` (`execution/transaction_post.py`)
Convert from a `BaseExecute` subclass (Pydantic model holding blocks and post) into a mixin class that implements `get_required_sender_balances()` and `execute_test()`. Instead of reading `self.blocks` and `self.post` from its own fields, it calls abstract bridge methods that the concrete test class must implement:
- `transaction_post_blocks() -> List[List[Transaction]]` — returns the transaction blocks to send
- `transaction_post_alloc() -> Alloc` — returns the expected post-state to verify
Each `BaseTest` subclass that mixes in `TransactionPostMixin` implements these bridge methods by returning its own data:
```
┌─────────────────┬──────────────────────────────┬──────────────────────────┐
│ Test class │ transaction_post_blocks() │ transaction_post_alloc() │
├─────────────────┼──────────────────────────────┼──────────────────────────┤
│ StateTest │ [[self.tx]] │ self.post │
├─────────────────┼──────────────────────────────┼──────────────────────────┤
│ BlockchainTest │ [b.txs for b in self.blocks] │ self.post │
├─────────────────┼──────────────────────────────┼──────────────────────────┤
│ TransactionTest │ [[self.tx]] │ {} │
├─────────────────┼──────────────────────────────┼──────────────────────────┤
│ BenchmarkTest │ [b.txs for b in self.blocks] │ self.post │
└─────────────────┴──────────────────────────────┴──────────────────────────┘
```
3. `BlobTransaction → BlobTransactionMixin` (`execution/blob_transaction.py`)
Same approach. Bridge methods:
- `blob_transaction_txs() -> List[NetworkWrappedTransaction | Transaction]`
- `blob_nonexisting_hashes() -> List[Hash] | None`
Mixed into `BlobsTest`.
4. Execute plugin (`cli/.../execute/execute.py`)
Simplify `BaseTestWrapper.__init__()`: instead of calling `self.execute(execute_format=...)` to get a `BaseExecute` instance and then calling methods on it, call `self.get_required_sender_balances(...)` and `self.execute_test(...)` directly on the test object.
5. Delete `execution/base.py`
`BaseExecute` is no longer needed. `ExecuteResult` can move to `specs/base.py` as a simple return type.
## Benefits
- Eliminates data duplication — no more copying blocks/post/txs into an intermediate object
- Single object per test — the test spec is the executable; no factory indirection
- Simpler plugin code — the execute plugin calls methods directly on the test instead of orchestrating two objects
- Easier to extend — adding a new execution strategy is just a new mixin, no need to also wire up format registration, `LabeledExecuteFormat`, and factory if/elif chains in every test class
- Better type safety — the bridge methods make explicit what data each mixin needs, rather than relying on runtime field copying
## Open Qs
- Can we mix two different mixins?
- Should we eliminate execution formats completely?
Contributor guide
Assessment
This issue has not been assessed yet.