Skip to content

Commit c8fc5fa

Browse files
refactor(benchmark): enhance worst bytecode test with contract deployment functions
1 parent 088a94c commit c8fc5fa

File tree

1 file changed

+185
-132
lines changed

1 file changed

+185
-132
lines changed

tests/benchmark/test_worst_bytecode.py

Lines changed: 185 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pytest
88

9+
from ethereum_test_base_types import Address
910
from ethereum_test_benchmark.benchmark_code_generator import JumpLoopGenerator
1011
from ethereum_test_forks import Fork
1112
from ethereum_test_tools import (
@@ -32,88 +33,12 @@
3233
XOR_TABLE = [Hash(i).sha256() for i in range(XOR_TABLE_SIZE)]
3334

3435

35-
@pytest.mark.parametrize(
36-
"opcode",
37-
[
38-
Op.EXTCODESIZE,
39-
Op.EXTCODEHASH,
40-
Op.CALL,
41-
Op.CALLCODE,
42-
Op.DELEGATECALL,
43-
Op.STATICCALL,
44-
Op.EXTCODECOPY,
45-
],
46-
)
47-
def test_worst_bytecode_single_opcode(
48-
blockchain_test: BlockchainTestFiller,
49-
pre: Alloc,
50-
fork: Fork,
51-
opcode: Op,
52-
env: Environment,
53-
gas_benchmark_value: int,
54-
):
55-
"""
56-
Test a block execution where a single opcode execution maxes out the gas
57-
limit, and the opcodes access a huge amount of contract code.
58-
59-
We first use a single block to deploy a factory contract that will be used
60-
to deploy a large number of contracts.
61-
62-
This is done to avoid having a big pre-allocation size for the test.
63-
64-
The test is performed in the last block of the test, and the entire block
65-
gas limit is consumed by repeated opcode executions.
66-
"""
67-
# The attack gas limit is the gas limit which the target tx will use The
68-
# test will scale the block gas limit to setup the contracts accordingly to
69-
# be able to pay for the contract deposit. This has to take into account
70-
# the 200 gas per byte, but also the quadratic memory expansion costs which
71-
# have to be paid each time the memory is being setup
72-
attack_gas_limit = gas_benchmark_value
73-
max_contract_size = fork.max_code_size()
74-
75-
gas_costs = fork.gas_costs()
76-
77-
# Calculate the absolute minimum gas costs to deploy the contract This does
78-
# not take into account setting up the actual memory (using KECCAK256 and
79-
# XOR) so the actual costs of deploying the contract is higher
80-
memory_expansion_gas_calculator = fork.memory_expansion_gas_calculator()
81-
memory_gas_minimum = memory_expansion_gas_calculator(new_bytes=len(bytes(max_contract_size)))
82-
code_deposit_gas_minimum = (
83-
fork.gas_costs().G_CODE_DEPOSIT_BYTE * max_contract_size + memory_gas_minimum
84-
)
85-
86-
intrinsic_gas_cost_calc = fork.transaction_intrinsic_cost_calculator()
87-
# Calculate the loop cost of the attacker to query one address
88-
loop_cost = (
89-
gas_costs.G_KECCAK_256 # KECCAK static cost
90-
+ math.ceil(85 / 32) * gas_costs.G_KECCAK_256_WORD # KECCAK dynamic
91-
# cost for CREATE2
92-
+ gas_costs.G_VERY_LOW * 3 # ~MSTOREs+ADDs
93-
+ gas_costs.G_COLD_ACCOUNT_ACCESS # Opcode cost
94-
+ 30 # ~Gluing opcodes
95-
)
96-
# Calculate the number of contracts to be targeted
97-
num_contracts = (
98-
# Base available gas = GAS_LIMIT - intrinsic - (out of loop MSTOREs)
99-
attack_gas_limit - intrinsic_gas_cost_calc() - gas_costs.G_VERY_LOW * 4
100-
) // loop_cost
101-
102-
# Set the block gas limit to a relative high value to ensure the code
103-
# deposit tx fits in the block (there is enough gas available in the block
104-
# to execute this)
105-
minimum_gas_limit = code_deposit_gas_minimum * 2 * num_contracts
106-
if env.gas_limit < minimum_gas_limit:
107-
raise Exception(
108-
f"`BENCHMARKING_MAX_GAS` ({env.gas_limit}) is no longer enough to support this test, "
109-
f"which requires {minimum_gas_limit} gas for its setup. Update the value or consider "
110-
"optimizing gas usage during the setup phase of this test."
111-
)
112-
113-
# The initcode will take its address as a starting point to the input to
114-
# the keccak hash function. It will reuse the output of the hash function
115-
# in a loop to create a large amount of seemingly random code, until it
116-
# reaches the maximum contract size.
36+
def deploy_initcode_template(pre: Alloc, fork: Fork) -> tuple[Address, Bytecode]:
37+
"""Deploy the initcode template contract."""
38+
# The initcode will take its address as a starting point to the input to the keccak
39+
# hash function.
40+
# It will reuse the output of the hash function in a loop to create a large amount of
41+
# seemingly random code, until it reaches the maximum contract size.
11742
initcode = (
11843
Op.MSTORE(0, Op.ADDRESS)
11944
+ While(
@@ -127,18 +52,21 @@ def test_worst_bytecode_single_opcode(
12752
)
12853
+ Op.POP
12954
),
130-
condition=Op.LT(Op.MSIZE, max_contract_size),
55+
condition=Op.LT(Op.MSIZE, fork.max_code_size()),
13156
)
13257
# Despite the whole contract has random bytecode, we make the first
13358
# opcode be a STOP so CALL-like attacks return as soon as possible,
13459
# while EXTCODE(HASH|SIZE) work as intended.
13560
+ Op.MSTORE8(0, 0x00)
136-
+ Op.RETURN(0, max_contract_size)
61+
+ Op.RETURN(0, fork.max_code_size())
13762
)
138-
initcode_address = pre.deploy_contract(code=initcode)
63+
return pre.deploy_contract(code=initcode), initcode
64+
13965

140-
# The factory contract will simply use the initcode that is already
141-
# deployed, and create a new contract and return its address if successful.
66+
def deploy_factory_contract(pre: Alloc, fork: Fork, initcode_address: Address) -> Address:
67+
"""Deploy the factory contract."""
68+
# The factory contract will simply use the initcode that is already deployed,
69+
# and create a new contract and return its address if successful.
14270
factory_code = (
14371
Op.EXTCODECOPY(
14472
address=initcode_address,
@@ -158,75 +86,200 @@ def test_worst_bytecode_single_opcode(
15886
+ Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1))
15987
+ Op.RETURN(0, 32)
16088
)
161-
factory_address = pre.deploy_contract(code=factory_code)
89+
return pre.deploy_contract(code=factory_code)
16290

163-
# The factory caller will call the factory contract N times, creating N new
164-
# contracts. Calldata should contain the N value.
91+
92+
def deploy_factory_caller_contract(pre: Alloc, fork: Fork, factory_address: Address) -> Address:
93+
"""Deploy the factory caller contract."""
94+
# The factory caller will call the factory contract N times, creating N new contracts.
95+
# Calldata should contain the N value.
16596
factory_caller_code = Op.CALLDATALOAD(0) + While(
16697
body=Op.POP(Op.CALL(address=factory_address)),
16798
condition=Op.PUSH1(1) + Op.SWAP1 + Op.SUB + Op.DUP1 + Op.ISZERO + Op.ISZERO,
16899
)
169-
factory_caller_address = pre.deploy_contract(code=factory_caller_code)
170100

171-
contracts_deployment_tx = Transaction(
172-
to=factory_caller_address,
173-
gas_limit=env.gas_limit,
174-
gas_price=10**6,
175-
data=Hash(num_contracts),
176-
sender=pre.fund_eoa(),
101+
return pre.deploy_contract(code=factory_caller_code)
102+
103+
104+
def deploy_attack_contract(
105+
pre: Alloc, fork: Fork, factory_address: Address, initcode: Bytecode, opcode: Op
106+
) -> Address:
107+
"""Deploy the attack contract."""
108+
# Setup memory for later CREATE2 address generation loop.
109+
# 0xFF+[Address(20bytes)]+[seed(32bytes)]+[initcode keccak(32bytes)]
110+
setup = (
111+
Op.MSTORE(0, factory_address)
112+
+ Op.MSTORE8(32 - 20 - 1, 0xFF)
113+
+ Op.CALLDATACOPY(dest_offset=32, offset=0, size=32)
114+
+ Op.MSTORE(64, initcode.keccak256())
177115
)
178116

179-
post = {}
180-
deployed_contract_addresses = []
181-
for i in range(num_contracts):
182-
deployed_contract_address = compute_create2_address(
183-
address=factory_address,
184-
salt=i,
185-
initcode=initcode,
186-
)
187-
post[deployed_contract_address] = Account(nonce=1)
188-
deployed_contract_addresses.append(deployed_contract_address)
117+
# setup_cost: G_VERY_LOW * 9 (PUSH) + G_VERY_LOW * 3 (MSTORE) + G_VERY_LOW (CALLDATACOPY)
189118

119+
# Attack call
190120
attack_call = Bytecode()
191121
if opcode == Op.EXTCODECOPY:
192122
attack_call = Op.EXTCODECOPY(address=Op.SHA3(32 - 20 - 1, 85), dest_offset=96, size=1000)
193123
else:
194124
# For the rest of the opcodes, we can use the same generic attack call
195125
# since all only minimally need the `address` of the target.
196126
attack_call = Op.POP(opcode(address=Op.SHA3(32 - 20 - 1, 85)))
197-
attack_code = (
198-
# Setup memory for later CREATE2 address generation loop.
199-
# 0xFF+[Address(20bytes)]+[seed(32bytes)]+[initcode keccak(32bytes)]
200-
Op.MSTORE(0, factory_address)
201-
+ Op.MSTORE8(32 - 20 - 1, 0xFF)
202-
+ Op.MSTORE(32, 0)
203-
+ Op.MSTORE(64, initcode.keccak256())
204-
# Main loop
205-
+ While(
206-
body=attack_call + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)),
207-
)
127+
128+
attack_code = setup + While(
129+
body=attack_call + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)),
208130
)
209131

210-
if len(attack_code) > max_contract_size:
211-
# TODO: A workaround could be to split the opcode code into multiple
212-
# contracts and call them in sequence.
213-
raise ValueError(
214-
f"Code size {len(attack_code)} exceeds maximum code size {max_contract_size}"
215-
)
216-
opcode_address = pre.deploy_contract(code=attack_code)
217-
opcode_tx = Transaction(
218-
to=opcode_address,
219-
gas_limit=attack_gas_limit,
220-
gas_price=10**9,
221-
sender=pre.fund_eoa(),
132+
# loop_cost = (
133+
# gas_costs.G_KECCAK_256 KECCAK static cost
134+
# + math.ceil(85 / 32) * gas_costs.G_KECCAK_256_WORD KECCAK dynamic cost for CREATE2
135+
# + gas_costs.G_VERY_LOW * ~MSTOREs+ADDs
136+
# + gas_costs.G_COLD_ACCOUNT_ACCESS Opcode cost
137+
# + 30 ~Gluing opcodes
138+
# )
139+
140+
return pre.deploy_contract(code=attack_code)
141+
142+
143+
@pytest.mark.parametrize(
144+
"opcode",
145+
[
146+
Op.EXTCODESIZE,
147+
Op.EXTCODEHASH,
148+
Op.CALL,
149+
Op.CALLCODE,
150+
Op.DELEGATECALL,
151+
Op.STATICCALL,
152+
Op.EXTCODECOPY,
153+
],
154+
)
155+
def test_worst_bytecode_single_opcode(
156+
blockchain_test: BlockchainTestFiller,
157+
pre: Alloc,
158+
fork: Fork,
159+
opcode: Op,
160+
env: Environment,
161+
gas_benchmark_value: int,
162+
tx_gas_limit_cap: int,
163+
):
164+
"""
165+
Test a block execution where a single opcode execution maxes out the gas limit,
166+
and the opcodes access a huge amount of contract code.
167+
168+
We first use a single block to deploy a factory contract that will be used to deploy
169+
a large number of contracts.
170+
171+
This is done to avoid having a big pre-allocation size for the test.
172+
173+
The test is performed in the last block of the test, and the entire block gas limit is
174+
consumed by repeated opcode executions.
175+
"""
176+
iteration_count = gas_benchmark_value // tx_gas_limit_cap
177+
178+
# The attack gas limit is the gas limit which the target tx will use
179+
# The test will scale the block gas limit to setup the contracts accordingly to be
180+
# able to pay for the contract deposit. This has to take into account the 200 gas per byte,
181+
# but also the quadratic memory expansion costs which have to be paid each time the
182+
# memory is being setup
183+
max_contract_size = fork.max_code_size()
184+
185+
gas_costs = fork.gas_costs()
186+
187+
intrinsic_gas_cost_calc = fork.transaction_intrinsic_cost_calculator()
188+
setup_cost = gas_costs.G_VERY_LOW * 13
189+
# Calculate the loop cost of the attacker to query one address
190+
loop_cost = (
191+
gas_costs.G_KECCAK_256 # KECCAK static cost
192+
+ math.ceil(85 / 32) * gas_costs.G_KECCAK_256_WORD # KECCAK dynamic cost for CREATE2
193+
+ gas_costs.G_VERY_LOW * 3 # ~MSTOREs+ADDs
194+
+ gas_costs.G_COLD_ACCOUNT_ACCESS # Opcode cost
195+
+ 30 # ~Gluing opcodes
222196
)
223197

198+
total_contracts = 0
199+
gas_remaining = gas_benchmark_value
200+
for _ in range(iteration_count):
201+
gas_available = min(tx_gas_limit_cap, gas_remaining)
202+
total_contracts += (
203+
# Base available gas = GAS_LIMIT - intrinsic - (out of loop MSTOREs)
204+
gas_available - intrinsic_gas_cost_calc() - setup_cost
205+
) // loop_cost
206+
gas_remaining -= gas_available
207+
208+
# Deployment Phase - Deploy factory contract
209+
initcode_address, initcode = deploy_initcode_template(pre, fork)
210+
factory_address = deploy_factory_contract(pre, fork, initcode_address)
211+
factory_caller_address = deploy_factory_caller_contract(pre, fork, factory_address)
212+
213+
# Deployment Phase - Deploy N contracts
214+
215+
# Calculate the absolute minimum gas costs to deploy the contract
216+
# This does not take into account setting up the actual memory (using KECCAK256 and XOR)
217+
# so the actual costs of deploying the contract is higher
218+
memory_expansion_gas_calculator = fork.memory_expansion_gas_calculator()
219+
memory_gas_minimum = memory_expansion_gas_calculator(new_bytes=len(bytes(max_contract_size)))
220+
code_deposit_gas_minimum = (
221+
fork.gas_costs().G_CODE_DEPOSIT_BYTE * max_contract_size + memory_gas_minimum
222+
)
223+
224+
contracts_deployment_txs = []
225+
deployment_cost_per_iteration = code_deposit_gas_minimum * 3
226+
deployed_contract_num = 0
227+
gas_remaining = gas_benchmark_value
228+
229+
while deployed_contract_num < total_contracts:
230+
gas_available = min(tx_gas_limit_cap, gas_remaining)
231+
gas_remaining -= gas_available
232+
num = (gas_available - intrinsic_gas_cost_calc()) // deployment_cost_per_iteration
233+
deployed_contract_num += num
234+
contracts_deployment_txs.append(
235+
Transaction(
236+
to=factory_caller_address,
237+
gas_limit=gas_available,
238+
data=Hash(num),
239+
sender=pre.fund_eoa(),
240+
)
241+
)
242+
243+
# Attack Phase
244+
opcode_address = deploy_attack_contract(pre, fork, factory_address, initcode, opcode)
245+
246+
opcode_txs = []
247+
gas_remaining = gas_benchmark_value
248+
access_contract_index = 0
249+
250+
for _ in range(iteration_count):
251+
gas_available = min(tx_gas_limit_cap, gas_remaining)
252+
gas_remaining -= gas_available
253+
num = (gas_available - intrinsic_gas_cost_calc() - setup_cost) // loop_cost
254+
opcode_txs.append(
255+
Transaction(
256+
to=opcode_address,
257+
data=Hash(access_contract_index),
258+
gas_limit=gas_available,
259+
sender=pre.fund_eoa(),
260+
)
261+
)
262+
access_contract_index += num
263+
264+
# Post State Verification
265+
post = {}
266+
deployed_contract_addresses = []
267+
for i in range(total_contracts):
268+
deployed_contract_address = compute_create2_address(
269+
address=factory_address,
270+
salt=i,
271+
initcode=initcode,
272+
)
273+
post[deployed_contract_address] = Account(nonce=1)
274+
deployed_contract_addresses.append(deployed_contract_address)
275+
276+
# Blockchain Test Execution
224277
blockchain_test(
225278
pre=pre,
226279
post=post,
227280
blocks=[
228-
Block(txs=[contracts_deployment_tx]),
229-
Block(txs=[opcode_tx]),
281+
Block(txs=contracts_deployment_txs),
282+
Block(txs=opcode_txs),
230283
],
231284
exclude_full_post_state_in_output=True,
232285
)

0 commit comments

Comments
 (0)