Skip to content

Commit 5e68194

Browse files
authored
test(benchmark): add coverage for CALLDATACOPY, CODECOPY, RETURNDATACOPY and MCOPY (#1800)
* benchmark: cover memory opcodes Signed-off-by: Ignacio Hagopian <jsign.uy@gmail.com> * lints Signed-off-by: Ignacio Hagopian <jsign.uy@gmail.com> * adjust parameters range Signed-off-by: Ignacio Hagopian <jsign.uy@gmail.com> --------- Signed-off-by: Ignacio Hagopian <jsign.uy@gmail.com>
1 parent 98cf1a8 commit 5e68194

File tree

1 file changed

+327
-0
lines changed

1 file changed

+327
-0
lines changed

tests/benchmark/test_worst_memory.py

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
"""
2+
abstract: Tests that benchmark EVMs in the worst-case memory opcodes.
3+
Tests that benchmark EVMs in the worst-case memory opcodes.
4+
5+
Tests that benchmark EVMs in the worst-case memory opcodes.
6+
"""
7+
8+
import pytest
9+
10+
from ethereum_test_base_types.base_types import Bytes
11+
from ethereum_test_forks import Fork
12+
from ethereum_test_tools import (
13+
Alloc,
14+
Bytecode,
15+
Environment,
16+
StateTestFiller,
17+
Transaction,
18+
)
19+
from ethereum_test_tools.vm.opcode import Opcodes as Op
20+
21+
from .helpers import code_loop_precompile_call
22+
23+
REFERENCE_SPEC_GIT_PATH = "TODO"
24+
REFERENCE_SPEC_VERSION = "TODO"
25+
26+
27+
class CallDataOrigin:
28+
"""Enum for calldata origins."""
29+
30+
TRANSACTION = 1
31+
CALL = 2
32+
33+
34+
@pytest.mark.valid_from("Cancun")
35+
@pytest.mark.parametrize(
36+
"origin",
37+
[
38+
pytest.param(CallDataOrigin.TRANSACTION, id="transaction"),
39+
pytest.param(CallDataOrigin.CALL, id="call"),
40+
],
41+
)
42+
@pytest.mark.parametrize(
43+
"size",
44+
[
45+
pytest.param(0, id="0 bytes"),
46+
pytest.param(100, id="100 bytes"),
47+
pytest.param(10 * 1024, id="10KiB"),
48+
pytest.param(1024 * 1024, id="1MiB"),
49+
],
50+
)
51+
@pytest.mark.parametrize(
52+
"fixed_src_dst",
53+
[
54+
True,
55+
False,
56+
],
57+
)
58+
@pytest.mark.parametrize(
59+
"non_zero_data",
60+
[
61+
True,
62+
False,
63+
],
64+
)
65+
def test_worst_calldatacopy(
66+
state_test: StateTestFiller,
67+
pre: Alloc,
68+
fork: Fork,
69+
origin: CallDataOrigin,
70+
size: int,
71+
fixed_src_dst: bool,
72+
non_zero_data: bool,
73+
):
74+
"""Test running a block filled with CALLDATACOPY executions."""
75+
env = Environment()
76+
77+
if size == 0 and non_zero_data:
78+
pytest.skip("Non-zero data with size 0 is not applicable.")
79+
80+
# We create the contract that will be doing the CALLDATACOPY multiple times.
81+
#
82+
# If `non_zero_data` is True, we leverage CALLDATASIZE for the copy length. Otherwise, since we
83+
# don't send zero data explicitly via calldata, PUSH the target size and use DUP1 to copy it.
84+
prefix = Bytecode() if non_zero_data or size == 0 else Op.PUSH3(size)
85+
src_dst = 0 if fixed_src_dst else Op.MOD(Op.GAS, 7)
86+
attack_block = Op.CALLDATACOPY(
87+
src_dst, src_dst, Op.CALLDATASIZE if non_zero_data or size == 0 else Op.DUP1
88+
)
89+
code = code_loop_precompile_call(prefix, attack_block, fork)
90+
code_address = pre.deploy_contract(code=code)
91+
92+
tx_target = code_address
93+
94+
# If the origin is CALL, we need to create a contract that will call the target contract with
95+
# the calldata.
96+
if origin == CallDataOrigin.CALL:
97+
# If `non_zero_data` is False we leverage just using zeroed memory. Otherwise, we
98+
# copy the calldata received from the transaction.
99+
prefix = (
100+
Op.CALLDATACOPY(Op.PUSH0, Op.PUSH0, Op.CALLDATASIZE) if non_zero_data else Bytecode()
101+
)
102+
arg_size = Op.CALLDATASIZE if non_zero_data else size
103+
code = prefix + Op.STATICCALL(
104+
address=code_address, args_offset=Op.PUSH0, args_size=arg_size
105+
)
106+
tx_target = pre.deploy_contract(code=code)
107+
108+
# If `non_zero_data` is True, we fill the calldata with deterministic random data.
109+
# Note that if `size == 0` and `non_zero_data` is a skipped case.
110+
data = Bytes([i % 256 for i in range(size)]) if non_zero_data else Bytes()
111+
112+
tx = Transaction(
113+
to=tx_target,
114+
gas_limit=env.gas_limit,
115+
data=data,
116+
sender=pre.fund_eoa(),
117+
)
118+
119+
state_test(
120+
genesis_environment=env,
121+
pre=pre,
122+
post={},
123+
tx=tx,
124+
)
125+
126+
127+
@pytest.mark.valid_from("Cancun")
128+
@pytest.mark.parametrize(
129+
"max_code_size_ratio",
130+
[
131+
pytest.param(0, id="0 bytes"),
132+
pytest.param(0.25, id="0.25x max code size"),
133+
pytest.param(0.50, id="0.50x max code size"),
134+
pytest.param(0.75, id="0.75x max code size"),
135+
pytest.param(1.00, id="max code size"),
136+
],
137+
)
138+
@pytest.mark.parametrize(
139+
"fixed_src_dst",
140+
[
141+
True,
142+
False,
143+
],
144+
)
145+
def test_worst_codecopy(
146+
state_test: StateTestFiller,
147+
pre: Alloc,
148+
fork: Fork,
149+
max_code_size_ratio: float,
150+
fixed_src_dst: bool,
151+
):
152+
"""Test running a block filled with CODECOPY executions."""
153+
env = Environment()
154+
max_code_size = fork.max_code_size()
155+
156+
size = int(max_code_size * max_code_size_ratio)
157+
158+
code_prefix = Op.PUSH32(size)
159+
src_dst = 0 if fixed_src_dst else Op.MOD(Op.GAS, 7)
160+
attack_block = Op.CODECOPY(src_dst, src_dst, Op.DUP1) # DUP1 copies size.
161+
code = code_loop_precompile_call(code_prefix, attack_block, fork)
162+
163+
# The code generated above is not guaranteed to be of max_code_size, so we pad it since
164+
# a test parameter targets CODECOPYing a contract with max code size. Padded bytecode values
165+
# are not relevant.
166+
code = code + Op.INVALID * (max_code_size - len(code))
167+
assert len(code) == max_code_size, (
168+
f"Code size {len(code)} is not equal to max code size {max_code_size}."
169+
)
170+
171+
tx = Transaction(
172+
to=pre.deploy_contract(code=code),
173+
gas_limit=env.gas_limit,
174+
sender=pre.fund_eoa(),
175+
)
176+
177+
state_test(
178+
genesis_environment=env,
179+
pre=pre,
180+
post={},
181+
tx=tx,
182+
)
183+
184+
185+
@pytest.mark.valid_from("Cancun")
186+
@pytest.mark.parametrize(
187+
"size",
188+
[
189+
pytest.param(0, id="0 bytes"),
190+
pytest.param(100, id="100 bytes"),
191+
pytest.param(10 * 1024, id="10KiB"),
192+
pytest.param(1024 * 1024, id="1MiB"),
193+
],
194+
)
195+
@pytest.mark.parametrize(
196+
"fixed_dst",
197+
[
198+
True,
199+
False,
200+
],
201+
)
202+
def test_worst_returndatacopy(
203+
state_test: StateTestFiller,
204+
pre: Alloc,
205+
fork: Fork,
206+
size: int,
207+
fixed_dst: bool,
208+
):
209+
"""Test running a block filled with RETURNDATACOPY executions."""
210+
env = Environment()
211+
max_code_size = fork.max_code_size()
212+
213+
# Create the contract that will RETURN the data that will be used for RETURNDATACOPY.
214+
# Random-ish data is injected at different points in memory to avoid making the content
215+
# predictable. If `size` is 0, this helper contract won't be used.
216+
code = (
217+
Op.MSTORE8(0, Op.GAS)
218+
+ Op.MSTORE8(size // 2, Op.GAS)
219+
+ Op.MSTORE8(size - 1, Op.GAS)
220+
+ Op.RETURN(0, size)
221+
)
222+
helper_contract = pre.deploy_contract(code=code)
223+
224+
# We create the contract that will be doing the RETURNDATACOPY multiple times.
225+
returndata_gen = Op.STATICCALL(address=helper_contract) if size > 0 else Bytecode()
226+
dst = 0 if fixed_dst else Op.MOD(Op.GAS, 7)
227+
attack_iter = Op.RETURNDATACOPY(dst, Op.PUSH0, Op.RETURNDATASIZE)
228+
229+
jumpdest = Op.JUMPDEST
230+
jump_back = Op.JUMP(len(returndata_gen))
231+
# The attack loop is constructed as:
232+
# ```
233+
# JUMPDEST(#)
234+
# RETURNDATACOPY(...)
235+
# RETURNDATACOPY(...)
236+
# ...
237+
# STATICCALL(address=helper_contract)
238+
# JUMP(#)
239+
# ```
240+
# The goal is that once per (big) loop iteration, the helper contract is called to
241+
# generate fresh returndata to continue calling RETURNDATACOPY.
242+
max_iters_loop = (
243+
max_code_size - 2 * len(returndata_gen) - len(jumpdest) - len(jump_back)
244+
) // len(attack_iter)
245+
code = (
246+
returndata_gen
247+
+ jumpdest
248+
+ sum([attack_iter] * max_iters_loop)
249+
+ returndata_gen
250+
+ jump_back
251+
)
252+
assert len(code) <= max_code_size, (
253+
f"Code size {len(code)} is not equal to max code size {max_code_size}."
254+
)
255+
256+
tx = Transaction(
257+
to=pre.deploy_contract(code=code),
258+
gas_limit=env.gas_limit,
259+
sender=pre.fund_eoa(),
260+
)
261+
262+
state_test(
263+
genesis_environment=env,
264+
pre=pre,
265+
post={},
266+
tx=tx,
267+
)
268+
269+
270+
@pytest.mark.valid_from("Cancun")
271+
@pytest.mark.parametrize(
272+
"size",
273+
[
274+
pytest.param(0, id="0 bytes"),
275+
pytest.param(100, id="100 bytes"),
276+
pytest.param(10 * 1024, id="10KiB"),
277+
pytest.param(1024 * 1024, id="1MiB"),
278+
],
279+
)
280+
@pytest.mark.parametrize(
281+
"fixed_src_dst",
282+
[
283+
True,
284+
False,
285+
],
286+
)
287+
def test_worst_mcopy(
288+
state_test: StateTestFiller,
289+
pre: Alloc,
290+
fork: Fork,
291+
size: int,
292+
fixed_src_dst: bool,
293+
):
294+
"""Test running a block filled with MCOPY executions."""
295+
env = Environment()
296+
max_code_size = fork.max_code_size()
297+
298+
mem_touch = (
299+
Op.MSTORE8(0, Op.GAS) + Op.MSTORE8(size // 2, Op.GAS) + Op.MSTORE8(size - 1, Op.GAS)
300+
if size > 0
301+
else Bytecode()
302+
)
303+
src_dst = 0 if fixed_src_dst else Op.MOD(Op.GAS, 7)
304+
attack_block = Op.MCOPY(src_dst, src_dst, size)
305+
306+
jumpdest = Op.JUMPDEST
307+
jump_back = Op.JUMP(len(mem_touch))
308+
max_iters_loop = (max_code_size - 2 * len(mem_touch) - len(jumpdest) - len(jump_back)) // len(
309+
attack_block
310+
)
311+
code = mem_touch + jumpdest + sum([attack_block] * max_iters_loop) + mem_touch + jump_back
312+
assert len(code) <= max_code_size, (
313+
f"Code size {len(code)} is not equal to max code size {max_code_size}."
314+
)
315+
316+
tx = Transaction(
317+
to=pre.deploy_contract(code=code),
318+
gas_limit=env.gas_limit,
319+
sender=pre.fund_eoa(),
320+
)
321+
322+
state_test(
323+
genesis_environment=env,
324+
pre=pre,
325+
post={},
326+
tx=tx,
327+
)

0 commit comments

Comments
 (0)