Skip to content

Commit 110d7b6

Browse files
refactor(benchmark): enhance worst bytecode test with contract deployment functions
1 parent 29a47f1 commit 110d7b6

File tree

1 file changed

+177
-122
lines changed

1 file changed

+177
-122
lines changed

tests/benchmark/test_worst_bytecode.py

Lines changed: 177 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import pytest
1111

12+
from ethereum_test_base_types import Address
1213
from ethereum_test_benchmark.benchmark_code_generator import JumpLoopGenerator
1314
from ethereum_test_forks import Fork
1415
from ethereum_test_tools import (
@@ -35,82 +36,8 @@
3536
XOR_TABLE = [Hash(i).sha256() for i in range(XOR_TABLE_SIZE)]
3637

3738

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

67+
68+
def deploy_factory_contract(pre: Alloc, fork: Fork, initcode_address: Address) -> Address:
69+
"""Deploy the factory contract."""
14070
# The factory contract will simply use the initcode that is already deployed,
14171
# and create a new contract and return its address if successful.
14272
factory_code = (
@@ -158,75 +88,200 @@ def test_worst_bytecode_single_opcode(
15888
+ Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1))
15989
+ Op.RETURN(0, 32)
16090
)
161-
factory_address = pre.deploy_contract(code=factory_code)
91+
return pre.deploy_contract(code=factory_code)
92+
16293

94+
def deploy_factory_caller_contract(pre: Alloc, fork: Fork, factory_address: Address) -> Address:
95+
"""Deploy the factory caller contract."""
16396
# The factory caller will call the factory contract N times, creating N new contracts.
16497
# Calldata should contain the N value.
16598
factory_caller_code = Op.CALLDATALOAD(0) + While(
16699
body=Op.POP(Op.CALL(address=factory_address)),
167100
condition=Op.PUSH1(1) + Op.SWAP1 + Op.SUB + Op.DUP1 + Op.ISZERO + Op.ISZERO,
168101
)
169-
factory_caller_address = pre.deploy_contract(code=factory_caller_code)
170102

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(),
103+
return pre.deploy_contract(code=factory_caller_code)
104+
105+
106+
def deploy_attack_contract(
107+
pre: Alloc, fork: Fork, factory_address: Address, initcode: Bytecode, opcode: Op
108+
) -> Address:
109+
"""Deploy the attack contract."""
110+
# Setup memory for later CREATE2 address generation loop.
111+
# 0xFF+[Address(20bytes)]+[seed(32bytes)]+[initcode keccak(32bytes)]
112+
setup = (
113+
Op.MSTORE(0, factory_address)
114+
+ Op.MSTORE8(32 - 20 - 1, 0xFF)
115+
+ Op.CALLDATACOPY(dest_offset=32, offset=0, size=32)
116+
+ Op.MSTORE(64, initcode.keccak256())
177117
)
178118

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)
119+
# setup_cost: G_VERY_LOW * 9 (PUSH) + G_VERY_LOW * 3 (MSTORE) + G_VERY_LOW (CALLDATACOPY)
189120

121+
# Attack call
190122
attack_call = Bytecode()
191123
if opcode == Op.EXTCODECOPY:
192124
attack_call = Op.EXTCODECOPY(address=Op.SHA3(32 - 20 - 1, 85), dest_offset=96, size=1000)
193125
else:
194126
# For the rest of the opcodes, we can use the same generic attack call
195127
# since all only minimally need the `address` of the target.
196128
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-
)
129+
130+
attack_code = setup + While(
131+
body=attack_call + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)),
208132
)
209133

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

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

0 commit comments

Comments
 (0)