Skip to content

feat(vm): Memory variable #1609

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ The output behavior of `fill` has changed ([#1608](https://github.com/ethereum/e

- ✨ Added the [EIP checklist template](https://eest.ethereum.org/main/writing_tests/checklist_templates/eip_testing_checklist_template/) that serves as a reference to achieve better coverage when implementing tests for new EIPs ([#1327](https://github.com/ethereum/execution-spec-tests/pull/1327)).
- ✨ Added [Post-Mortems of Missed Test Scenarios](https://eest.ethereum.org/main/writing_tests/post_mortems/) to the documentation that serves as a reference list of all cases that were missed during the test implementation phase of a new EIP, and includes the steps taken in order to prevent similar test cases to be missed in the future ([#1327](https://github.com/ethereum/execution-spec-tests/pull/1327)).
- ✨ Added the `MemoryVariable` EVM abstraction to generate more readable bytecode when there's heavy use of variables that are stored in memory ([#1609](https://github.com/ethereum/execution-spec-tests/pull/1609)).

### πŸ§ͺ Test Cases

Expand Down
2 changes: 2 additions & 0 deletions src/ethereum_test_tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
EVMCodeType,
Macro,
Macros,
MemoryVariable,
Opcode,
OpcodeCallArg,
Opcodes,
Expand Down Expand Up @@ -127,6 +128,7 @@
"Initcode",
"Macro",
"Macros",
"MemoryVariable",
"NetworkWrappedTransaction",
"Opcode",
"OpcodeCallArg",
Expand Down
3 changes: 2 additions & 1 deletion src/ethereum_test_vm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

from .bytecode import Bytecode
from .evm_types import EVMCodeType
from .helpers import call_return_code
from .helpers import MemoryVariable, call_return_code
from .opcode import Macro, Macros, Opcode, OpcodeCallArg, Opcodes, UndefinedOpcodes

__all__ = (
"Bytecode",
"EVMCodeType",
"Macro",
"Macros",
"MemoryVariable",
"Opcode",
"OpcodeCallArg",
"Opcodes",
Expand Down
75 changes: 75 additions & 0 deletions src/ethereum_test_vm/helpers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,83 @@
"""Helper functions for the EVM."""

from .bytecode import Bytecode
from .opcode import Opcodes as Op


class MemoryVariable(Bytecode):
"""
Variable abstraction to help keep track values that are stored in memory.

To use, simply declare a variable with an unique offset that is not used by any other variable.

The variable then can be used in-place to read the value from memory:

```python
v = MemoryVariable(128)

bytecode = Op.ADD(v, Op.CALLDATASIZE())
```

The previous example is equivalent to:

```python
bytecode = Op.ADD(Op.MLOAD(offset=128), Op.CALLDATASIZE())
```

The variable also contains methods to add and subtract values from the memory offset.

```python
v = MemoryVariable(128)

bytecode = (
v.store(0xff)
+ v.add(1)
+ v.return_value()
)
```

The previous example is equivalent to:

```python
bytecode = (
Op.MSTORE(offset=128, value=0xff)
+ Op.MSTORE(offset=128, value=Op.ADD(Op.MLOAD(offset=128), 1))
+ Op.RETURN(offset=128, size=32)
)
```

"""

offset: int

def __new__(cls, offset: int):
"""
Initialize EVM memory variable.

When used with normal bytecode, this class simply returns the MLOAD with the provided
offset.
"""
instance = super().__new__(cls, Op.MLOAD(offset=offset))
instance.offset = offset
return instance

def store(self, value: int | Bytecode):
"""Store value in memory."""
return Op.MSTORE(offset=self.offset, value=value)

def add(self, value: int | Bytecode):
"""Add value to memory."""
return Op.MSTORE(offset=self.offset, value=Op.ADD(Op.MLOAD(offset=self.offset), value))

def sub(self, value: int | Bytecode):
"""Subtract value from memory."""
return Op.MSTORE(offset=self.offset, value=Op.SUB(Op.MLOAD(offset=self.offset), value))

def return_value(self):
"""Return value from memory."""
return Op.RETURN(offset=self.offset, size=32)


def call_return_code(opcode: Op, success: bool, *, revert: bool = False) -> int:
"""Return return code for a CALL operation."""
if opcode in [Op.CALL, Op.CALLCODE, Op.DELEGATECALL, Op.STATICCALL]:
Expand Down
66 changes: 46 additions & 20 deletions tests/frontier/scenarios/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from enum import Enum

from ethereum_test_forks import Fork, Frontier
from ethereum_test_tools import Address, Alloc, Bytecode, Conditional
from ethereum_test_tools.vm.opcode import Opcodes as Op
from ethereum_test_tools import Address, Alloc, Bytecode, Conditional, MemoryVariable
from ethereum_test_tools import Opcodes as Op


class ScenarioExpectOpcode(Enum):
Expand Down Expand Up @@ -190,20 +190,40 @@ def make_gas_hash_contract(pre: Alloc) -> Address:
So that if we can't check exact value in expect section,
we at least could spend unique gas amount.
"""
# EVM memory variables
variable_byte_offset = MemoryVariable(0)
variable_current_byte = MemoryVariable(32)

# Code for memory initialization
initialize_code = variable_byte_offset.store(0)
calldata_copy = Op.JUMPDEST + Op.CALLDATACOPY(
dest_offset=variable_current_byte.offset + 32 - 1,
Copy link
Contributor

Choose a reason for hiding this comment

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

32, -1 magic numbers?

Copy link
Member Author

Choose a reason for hiding this comment

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

It was 63 before, we were putting it in the last byte of the variable.
So the offset, plus its size (32), minus the one byte.

offset=variable_byte_offset,
size=1,
)

# Code offsets
offset_calldata_copy = len(initialize_code)
offset_conditional = offset_calldata_copy + len(calldata_copy)

# Deploy contract
gas_hash_address = pre.deploy_contract(
code=Op.MSTORE(0, 0)
+ Op.JUMPDEST
+ Op.CALLDATACOPY(63, Op.MLOAD(0), 1)
+ Op.JUMPDEST
code=initialize_code
Copy link
Contributor

@winsvega winsvega May 15, 2025

Choose a reason for hiding this comment

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

I prefer not to subfunc the code for readability

+ calldata_copy # offset_calldata_copy
Copy link
Contributor

Choose a reason for hiding this comment

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

This makes it less readable

+ Op.JUMPDEST # offset_conditional
+ Conditional(
condition=Op.ISZERO(Op.MLOAD(32)),
if_true=Op.MSTORE(0, Op.ADD(1, Op.MLOAD(0)))
+ Conditional(
condition=Op.GT(Op.MLOAD(0), 32),
if_true=Op.RETURN(0, 0),
if_false=Op.JUMP(5),
condition=Op.ISZERO(variable_current_byte),
if_true=(
# Increase the calldata byte offset, and if it's greater than the calldata size,
# return, otherwise jump to the calldata copy code and read the next byte.
variable_byte_offset.add(1)
+ Conditional(
condition=Op.GT(variable_byte_offset, Op.CALLDATASIZE()),
if_true=Op.RETURN(offset=0, size=0),
if_false=Op.JUMP(offset_calldata_copy),
)
),
if_false=Op.MSTORE(32, Op.SUB(Op.MLOAD(32), 1)) + Op.JUMP(14),
if_false=(variable_current_byte.sub(1) + Op.JUMP(offset_conditional)),
)
)
return gas_hash_address
Expand Down Expand Up @@ -234,19 +254,25 @@ def make_invalid_opcode_contract(pre: Alloc, fork: Fork) -> Address:
if op not in valid_opcode_values:
invalid_opcodes.append(op)

variable_results_sum = MemoryVariable(0)
variable_opcode = MemoryVariable(32)

code = Bytecode(
sum(
Op.MSTORE(64, opcode)
+ Op.MSTORE(
32,
Op.CALL(gas=50000, address=invalid_opcode_caller, args_offset=64, args_size=32),
variable_opcode.store(opcode)
+ variable_results_sum.add(
Op.CALL(
gas=50000,
address=invalid_opcode_caller,
args_offset=variable_opcode.offset,
args_size=32,
),
)
+ Op.MSTORE(0, Op.ADD(Op.MLOAD(0), Op.MLOAD(32)))
for opcode in invalid_opcodes
)
# If any of invalid instructions works, mstore[0] will be > 1
+ Op.MSTORE(0, Op.ADD(Op.MLOAD(0), 1))
+ Op.RETURN(0, 32)
+ variable_results_sum.add(1)
+ variable_results_sum.return_value()
)

return pre.deploy_contract(code=code)
Loading