Skip to content

feat: add depth-based worst-case attack benchmarks for execute mode#1885

Closed
CPerezz wants to merge 2 commits intoethereum:forks/amsterdamfrom
CPerezz:feat/depth-benchmarks
Closed

feat: add depth-based worst-case attack benchmarks for execute mode#1885
CPerezz wants to merge 2 commits intoethereum:forks/amsterdamfrom
CPerezz:feat/depth-benchmarks

Conversation

@CPerezz
Copy link
Copy Markdown
Contributor

@CPerezz CPerezz commented Dec 10, 2025

This commit introduces a comprehensive worst-case attack benchmark suite that exploits Patricia Merkle Trie computational complexity through:

  • Pre-mined CREATE2 addresses with configurable prefix sharing depth
  • Deep storage slots with configurable trie depth
  • Attack orchestrator contract for efficient batch attacks
  • Execute mode support with pre-allocation pattern

Key changes:

  • Add test_worst_case_attack_execute.py for execute mode testing
  • Include AttackOrchestrator.sol for batch attack execution
  • Add depth_9.sol and depth_10.sol contracts with deep storage
  • Include pre-mined address data files (s9_acc3/4/5.json)
  • Dynamic deep slot extraction based on storage_depth parameter
  • Pre-allocation of all contracts and funds in pre-state
  • Single-block attack transaction pattern for execute mode

The test deploys contracts at addresses with shared prefixes to maximize trie traversal overhead, then uses an orchestrator to efficiently attack deep storage slots, creating worst-case computational scenarios for benchmarking Ethereum client performance.

This commit introduces a comprehensive worst-case attack benchmark suite
that exploits Patricia Merkle Trie computational complexity through:

- Pre-mined CREATE2 addresses with configurable prefix sharing depth
- Deep storage slots with configurable trie depth
- Attack orchestrator contract for efficient batch attacks
- Execute mode support with pre-allocation pattern

Key changes:
- Add test_worst_case_attack_execute.py for execute mode testing
- Include AttackOrchestrator.sol for batch attack execution
- Add depth_9.sol and depth_10.sol contracts with deep storage
- Include pre-mined address data files (s9_acc3/4/5.json)
- Dynamic deep slot extraction based on storage_depth parameter
- Pre-allocation of all contracts and funds in pre-state
- Single-block attack transaction pattern for execute mode

The test deploys contracts at addresses with shared prefixes to maximize
trie traversal overhead, then uses an orchestrator to efficiently attack
deep storage slots, creating worst-case computational scenarios for
benchmarking Ethereum client performance.
for aux_account in auxiliary_accounts:
aux_address = Address(aux_account)
# Fund with 1 wei to ensure the account exists in the trie
pre.fund_eoa(aux_address, value=1)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think this is why the current execute remote flow is failing. From my understanding of the implementation, here we intend to fund 1 wei to aux_address. However, pre.fund_eoa() does not work this way, it creates a new EOA instead of funding an existing account.

I experimented with manually injecting the account, but this only works in fill mode:

pre[aux_address] = Account(address=aux_address, balance=1)

The correct solution might be to create funding transactions instead:

funding_tx = Transaction(
    to=aux_address,
    value=1,  # 1 wei to create the account in the trie
    gas_limit=21_000,
    sender=deployer_eoa,
)
funding_txs.append(funding_tx)

- Split transactions across blocks respecting gas_benchmark_value
- Deploy orchestrator first with nonce 0 for deterministic address
- Fix CREATE2 address derivation in AttackOrchestrator assembly
- Scale test to 15,000 contracts
Comment on lines +326 to +334
deploy_tx = Transaction(
to=NICK_DEPLOYER,
data=calldata,
gas_limit=deployment_gas_per_contract + 50000, # Add buffer for CREATE2 operation
sender=deployer_address,
max_fee_per_gas=10_000_000_000, # 10 gwei
max_priority_fee_per_gas=1_000_000_000, # 1 gwei
)
deployment_txs.append(deploy_tx)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This sends 15k txs (as in execute mode even if we split them in blocks, they're all sent at the same time).
Thus, killink any RPC endpoint you have even locally.

For 1000 contracts this all works. But for 15k (the goal fo this tests) it just kills Anvil, Geth etc...

Unsure how to proceed..

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We need some rate limit that wait until the first 1000 transactions are complete before proceeding the next 1000 transactions in execute remote. Thanks for sharing these number, i will try with 750 at first

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can also deploy more contracts in a single tx by creating a contract which calls multiple times in 0x4e59b44847b379578588920ca78fbf26c0b4956c (CREATE2 factory). As calldata we give the starting salt, and for each call into CREATE2 it increases the salt. Alternatively can also not put that in calldata and just store the current salt in storage. Only call into the CREATE2 factory if there is enough gas left. Depending on the size of the contract which we are deploying (this one does not look super big) we will get at least 3x less txs but possibly even more.

Comment on lines +493 to +513
# Extract the deepest storage slot from the contract source
# The deepest slot is the last sstore in the constructor
# TODO: This is garbage. But we have no other way to actually get this info.
# We can't make RPC requests from within the test.
sol_filename = f"depth_{storage_depth}.sol"
sol_path = Path(__file__).parent / sol_filename

with open(sol_path, 'r') as f:
content = f.read()
# Find all sstore operations in the constructor
import re
sstore_pattern = r'sstore\((0x[0-9a-fA-F]+),\s*1\)'
sstores = re.findall(sstore_pattern, content)
if sstores:
# The last sstore is the deepest slot
DEEP_SLOT = int(sstores[-1], 16)
else:
raise Exception(f"Could not find sstore operations in {sol_filename}")

print(f"\nDeep storage slot for depth {storage_depth}: {hex(DEEP_SLOT)}")
print(f"Expected: Changed from initial value of 1 to {attack_value} after attack")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I need to do all this crap such that I can get some values from inside the contract (cause I can't call RPCs directly through the tooling).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can't we rather store metadata with the solidity source code? RPC calls will not work because filled tests, when consumed by the client test executors don't even spawn an RPC node.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The depth contracts look alike: the difference is the storage written to in the constructor and the target storage slot. We can encode these as constructor arguments (so there is then also no need for depth_9.sol and depth_10.sol, the storage keys to write to and attack are then in a metadata list, which are thus the values provided currently in those files)

Comment on lines +535 to +539
blockchain_test(
pre=pre,
blocks=blocks,
post=post,
) No newline at end of file
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is (I assume) useless? As basically it just doesn't propagate blocks as I would expect it to. Neither it waits between blocks to not send all the txs at once.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this to counter the RPC crashing when sending 15k transactions at once? I think we need a proper fix in the execute command machinery to handle extreme cases like this (perhaps setting a max-txs-per-request and split this line into multiple ones?)

return json.load(f)


def compile_attack_orchestrator():
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We can get rid of this and just have the bytecode file.

@CPerezz
Copy link
Copy Markdown
Contributor Author

CPerezz commented Dec 11, 2025

ping @danceratopz @LouisTsai-Csie

Notice that I can simply take a turn and avoid deploying anything here. (Just the orchestator).

Thus, on this way, I can just send a single tx. And that would be the whole test. But, this would make this test only runnable on bloatnet. (Which is fine).

Copy link
Copy Markdown
Member

@marioevz marioevz left a comment

Choose a reason for hiding this comment

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

Left some comments with improvement suggestions. I'm still not completely sold on the need for compiling solidity, and that's because it's a pain to maintain and make the tests forward compatible.

Comment on lines +259 to +260
max_fee_per_gas=10_000_000_000, # 10 gwei
max_priority_fee_per_gas=1_000_000_000, # 1 gwei
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is automatically calculated by fill/execute, no need to add this.

Suggested change
max_fee_per_gas=10_000_000_000, # 10 gwei
max_priority_fee_per_gas=1_000_000_000, # 1 gwei

# Each SSTORE to a new slot costs ~22100 gas (cold storage)
constructor_gas = storage_depth * 22100 # Dynamic based on storage depth
deployment_gas_per_contract = (
32000 # CREATE2 base cost
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
32000 # CREATE2 base cost
gas_costs.G_TRANSACTION_CREATE # CREATE2 base cost

try:
create2_data = load_create2_data(storage_depth, account_depth)
except FileNotFoundError as e:
pytest.skip(f"Skipping test: {e}")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should be a failure. If we modify parametrization and add a bunch of invalid tests for which json files do not exist we will not notice as they will only show up as skipped tests.

raise FileNotFoundError(f"Pre-mined data not found: {json_filename}")

with open(json_path, 'r') as f:
return json.load(f)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These files have a known format, we need to use Pydantic to load these into the proper types:

class ContractInFile(base_model):
    salt: int
    contract_address: Address
    auxiliary_accounts: List[Address]

class ContractsFile(base_model):
    deployer: Address
    init_code_hash: Hash
    target_depth: int
    num_contracts: int
    total_time: float
    contracts: List[ContractInFile]

Or something like that, then you can do ContractsFile.model_validate_json to load the file's contents.

# We need to use transactions, not pre-allocation, for execute mode
funding_txs = []
for i, contract_data in enumerate(contracts):
auxiliary_accounts = contract_data.get("auxiliary_accounts", [])
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
auxiliary_accounts = contract_data.get("auxiliary_accounts", [])
auxiliary_accounts = contract_data.auxiliary_accounts

After we update the loaded files to use Pydantic.

NICK_DEPLOYER = Address("0x4e59b44847b379578588920ca78fbf26c0b4956c")


def load_create2_data(storage_depth, account_depth):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This function should be a pytest fixture instead, which allows pytest to not have to reload the entire file each time for every test.

Comment on lines +313 to +319
create2_input = (
bytes.fromhex("ff") +
bytes(NICK_DEPLOYER) +
salt_bytes +
keccak256(bytes(init_code))
)
contract_addr = keccak256(create2_input)[-20:] # Last 20 bytes
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Comment on lines +394 to +400
import rlp
from eth_utils import keccak
# Nonce = 0 since orchestrator is deployed first
nonce_for_orchestrator = 0
deployer_bytes = bytes.fromhex(str(deployer_address)[2:])
orchestrator_address_bytes = keccak(rlp.encode([deployer_bytes, nonce_for_orchestrator]))[12:]
orchestrator_address = Address("0x" + orchestrator_address_bytes.hex())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
import rlp
from eth_utils import keccak
# Nonce = 0 since orchestrator is deployed first
nonce_for_orchestrator = 0
deployer_bytes = bytes.fromhex(str(deployer_address)[2:])
orchestrator_address_bytes = keccak(rlp.encode([deployer_bytes, nonce_for_orchestrator]))[12:]
orchestrator_address = Address("0x" + orchestrator_address_bytes.hex())
orchestrator_address = orchestrator_deploy_tx.created_contract

Comment on lines +535 to +539
blockchain_test(
pre=pre,
blocks=blocks,
post=post,
) No newline at end of file
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this to counter the RPC crashing when sending 15k transactions at once? I think we need a proper fix in the execute command machinery to handle extreme cases like this (perhaps setting a max-txs-per-request and split this line into multiple ones?)

Comment on lines +493 to +513
# Extract the deepest storage slot from the contract source
# The deepest slot is the last sstore in the constructor
# TODO: This is garbage. But we have no other way to actually get this info.
# We can't make RPC requests from within the test.
sol_filename = f"depth_{storage_depth}.sol"
sol_path = Path(__file__).parent / sol_filename

with open(sol_path, 'r') as f:
content = f.read()
# Find all sstore operations in the constructor
import re
sstore_pattern = r'sstore\((0x[0-9a-fA-F]+),\s*1\)'
sstores = re.findall(sstore_pattern, content)
if sstores:
# The last sstore is the deepest slot
DEEP_SLOT = int(sstores[-1], 16)
else:
raise Exception(f"Could not find sstore operations in {sol_filename}")

print(f"\nDeep storage slot for depth {storage_depth}: {hex(DEEP_SLOT)}")
print(f"Expected: Changed from initial value of 1 to {attack_value} after attack")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can't we rather store metadata with the solidity source code? RPC calls will not work because filled tests, when consumed by the client test executors don't even spawn an RPC node.

Copy link
Copy Markdown
Member

@jochem-brouwer jochem-brouwer left a comment

Choose a reason for hiding this comment

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

Some comments.

Regarding the need to compile solidity, for these kind of setups it certainly lowers the time spent on creating these contracts, because without solidity we have to write everything by hand. That would however be super optimized code (in most cases). For this one, ERC20 contracts with some extra logic have to be created, and it would be rather impossible to create such contract by hand without a compiler.

However, I'm not sure why the code here needs to be an ERC20 contract. If the attack here is a trie-depth based attack then we could also do with minimal code, right? This would write the storage in the constructor and at runtime it would run the attack (writing the value to the target storage slot).

These mined storage keys/account keys, how did you retrieve those? 😄 👍

// The layout should be: 0xff (1 byte) + deployer (20 bytes) + salt (32 bytes) + codeHash (32 bytes)
// Total: 85 bytes
let memPtr := 0x00
mstore8(memPtr, 0xff) // 0xff prefix at byte 0
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There are constants stored in this for loop which do not need to be there. You can move these constant mstores before the loop (to init the constant parts of memory once)

let memPtr := 0x00
mstore8(memPtr, 0xff) // 0xff prefix at byte 0
mstore(add(memPtr, 0x01), shl(96, deployer)) // deployer address at bytes 1-20
mstore(add(memPtr, 0x15), i) // salt (i as uint256) at bytes 21-52
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For clarity I would not use hex here, but rather ints, so it is easier to check if these constants are correct.

// Compute CREATE2 address
let target := and(
keccak256(0x00, 0x55), // Hash 85 bytes
0xffffffffffffffffffffffffffffffffffffffff // Mask to 20 bytes
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Masking is not necessary. If you CALL something then the EVM will mask it (so hide the topmost 12 bytes)


This test implements a worst-case scenario for Ethereum block processing that exploits
the computational complexity of Patricia Merkle Trie operations. It uses CREATE2 to deploy
contracts at pre-mined addresses with shared prefixes, maximizing trie traversal depth.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this should be somewhat more explicit: the deployed contracts will write to the storage there, but in such way that with a minimal set of key/values we get a certain trie depth (storage slots all share some prefix).

As a side-note, this is a good way to test the impact of "depth" but it does not test the impact of the actual contract (if it would actually have the depth if we would consider the MPT balanced - here it is completely the opposite and super unbalanced in one direction 😆 ). So if a client reads from a flat database (can directly load storage key without having to traverse the trie first), then if we read a storage key the more keys which "share" some prefix might have impact on the read speed (so the difference between the unbalanced/balanced tree on the state). This is obviously dependent on the database backend used and the db config.

)

# Attack function selector for WorstCaseERC20.attack(uint256)
ATTACK_SELECTOR = 0x64dd891a # attack(uint256) - verified with: cast sig "attack(uint256)"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

FYI, this is also keccak256("attack(uint256)")[:4] 😄 👍

salt_bytes +
keccak256(bytes(init_code))
)
contract_addr = keccak256(create2_input)[-20:] # Last 20 bytes
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Isn't this part of the json already? .contract_address field? Or was that deployed on a different address (so not the 0x4e59b44847b379578588920ca78fbf26c0b4956c)?

Comment on lines +326 to +334
deploy_tx = Transaction(
to=NICK_DEPLOYER,
data=calldata,
gas_limit=deployment_gas_per_contract + 50000, # Add buffer for CREATE2 operation
sender=deployer_address,
max_fee_per_gas=10_000_000_000, # 10 gwei
max_priority_fee_per_gas=1_000_000_000, # 1 gwei
)
deployment_txs.append(deploy_tx)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can also deploy more contracts in a single tx by creating a contract which calls multiple times in 0x4e59b44847b379578588920ca78fbf26c0b4956c (CREATE2 factory). As calldata we give the starting salt, and for each call into CREATE2 it increases the salt. Alternatively can also not put that in calldata and just store the current salt in storage. Only call into the CREATE2 factory if there is enough gas left. Depending on the size of the contract which we are deploying (this one does not look super big) we will get at least 3x less txs but possibly even more.

attack_txs.append(attack_tx)

# Create blocks with all transactions in proper order
# IMPORTANT: Deploy orchestrator FIRST (nonce 0) to have deterministic address
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can deploy using Nick's method to be sure the deterministic address is equal 😉

Comment on lines +493 to +513
# Extract the deepest storage slot from the contract source
# The deepest slot is the last sstore in the constructor
# TODO: This is garbage. But we have no other way to actually get this info.
# We can't make RPC requests from within the test.
sol_filename = f"depth_{storage_depth}.sol"
sol_path = Path(__file__).parent / sol_filename

with open(sol_path, 'r') as f:
content = f.read()
# Find all sstore operations in the constructor
import re
sstore_pattern = r'sstore\((0x[0-9a-fA-F]+),\s*1\)'
sstores = re.findall(sstore_pattern, content)
if sstores:
# The last sstore is the deepest slot
DEEP_SLOT = int(sstores[-1], 16)
else:
raise Exception(f"Could not find sstore operations in {sol_filename}")

print(f"\nDeep storage slot for depth {storage_depth}: {hex(DEEP_SLOT)}")
print(f"Expected: Changed from initial value of 1 to {attack_value} after attack")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The depth contracts look alike: the difference is the storage written to in the constructor and the target storage slot. We can encode these as constructor arguments (so there is then also no need for depth_9.sol and depth_10.sol, the storage keys to write to and attack are then in a metadata list, which are thus the values provided currently in those files)

post[contract_address] = Account(
# Contract should exist with code
storage={
DEEP_SLOT: expected_value, # Exact value from last attack
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shouldn't this be attack_value?

@SamWilsn SamWilsn changed the base branch from forks/osaka to forks/amsterdam December 16, 2025 21:36

// Set all mined storage slots to 1
assembly {
sstore(0xfeb1bc66963690bd7d902e86ccaf4e0fa1ea72277653d012a3fed288892770fc, 1)
Copy link
Copy Markdown
Collaborator

@jsign jsign Dec 16, 2025

Choose a reason for hiding this comment

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

Does the address miner also put as a constraint that the preimages have this "nibble by nibble" overlap for the required depth?

Mostly asking because what matters for this to be deep branches is that the hashes of the storage slot numbers satisfy this, not the storage numbers themselves (i.e. the storage slots are hashed to define which is the storage trie address).

(Maybe I'm missing something else from the whole PR setup!)

Edit: or maybe the code that generates this contract is putting the hashes instead of the preimages?

@LouisTsai-Csie
Copy link
Copy Markdown
Collaborator

Close as PR #1976 done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants