From 92840e910728138330ab3da669a4c0bc49e35981 Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Thu, 4 Sep 2025 14:40:03 +0200 Subject: [PATCH 01/12] feat(tests): add initial XEN test --- tests/benchmark/mainnet/__init__.py | 1 + tests/benchmark/mainnet/test_state_xen.py | 214 ++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tests/benchmark/mainnet/__init__.py create mode 100644 tests/benchmark/mainnet/test_state_xen.py diff --git a/tests/benchmark/mainnet/__init__.py b/tests/benchmark/mainnet/__init__.py new file mode 100644 index 00000000000..4cea0126936 --- /dev/null +++ b/tests/benchmark/mainnet/__init__.py @@ -0,0 +1 @@ +"""abstract: Tests which run on top of mainnet state, in practical mainnet situations.""" diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py new file mode 100644 index 00000000000..a14327af908 --- /dev/null +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -0,0 +1,214 @@ +""" +abstract: Tests practical scenarios on Mainnet with the XEN (which has a big state) contract + +Tests practical scenarios on Mainnet with the XEN (which has a big state) contract. +This currently has one situation, but will be expanded with other scenarios. +The goal is to bloat as much of the big state of XEN as possible. XEN has a big state trie. +We therefore want to do as much state situations (either read or write: likely write is +the most expensive situation). +NOTE: this is thus NOT the worst-case scenario, since we can remove the overhead execution +computations for XEN and only do state operations on an account with a big state attached to it. +This therefore only tests the practical, "real life" and most likely scenario. +However, with enough funds (to bloat a contract state), this is thus not the worst scenario. +""" + +import math + +import pytest + +from ethereum_test_forks import Fork +from ethereum_test_tools import ( + Account, + Alloc, + Block, + BlockchainTestFiller, + Environment, + Hash, + Transaction, + While, + compute_create2_address, +) +from ethereum_test_tools import Macros as Om +from ethereum_test_tools.vm.opcode import Opcodes as Op + +# TODO +# The current test does only claimRank(1) and then waits `SECONDS_IN_DAY = 3_600 * 24;` plus 1 +# (see https://etherscan.io/token/0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8#code) and then +# claimMintReward() from CREATE2-create proxy accounts (to save gas). +# This might not be the worst scenario, for instance `claimMintRewardAndShare(address,uint256)` +# might yield even worse scenarios (or scenarios regarding "staking") +# These scenarios will be added. + + +# TODO: set correct fork, XEN might reject on historical forks due to e.g. non-existent opcodes +# NOTE: deploy both XEN (0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8) +# and Math (0x4bBA9B6B49f3dFA6615f079E9d66B0AA68B04A4d) in prestate for the Mainnet scenario! +@pytest.mark.valid_from("Frontier") +def test_xen_claimrank_and_mint( + blockchain_test: BlockchainTestFiller, + fork: Fork, + pre: Alloc, + env: Environment, + gas_benchmark_value: int, +): + """Simple XEN scenario to claimRank(1) and claimMintReward().""" + attack_gas_limit = gas_benchmark_value + fee_recipient = pre.fund_eoa(amount=1) + + # timestamp to use for the initial block. Timestamp of later blocks are manually added/changed. + timestamp = 12 + + # TODO: adjust this to the right amount of the actual performance test block + num_xen = 10 + + # NOTE: these contracts MUST be specified for this test to work + # TODO: check how/if EEST enforces this + xen_contract = pre.deploy_contract("", label="XEN_CONTRACT") + # NOTE: from the test perspective this contract should not be specified + # However, the XEN contract needs the Math contract. If this is not provided, the transaction + # will likely revert ("fail"). This is not what we want. We want state bloat! + pre.deploy_contract("", label="MATH_CONTRACT") + + # This is after (!!) deployment (so step 2, not 1): claimMintReward() + calldata_claim_mint_reward = bytes.fromhex("52c7f8dc") + after_initcode_callata = Om.MSTORE(bytes.fromhex("52c7f8dc")) + Op.CALL( + address=xen_contract, args_size=len(calldata_claim_mint_reward) + ) + + # Calldata for claimRank(1) + calldata_claim_rank = bytes.fromhex( + "9ff054df0000000000000000000000000000000000000000000000000000000000000001" + ) + + # claimRank(1) and deposits the code to claimMintReward() if this contract is called + initcode = ( + Om.MSTORE(calldata_claim_rank) + + Op.CALL(address=xen_contract, args_size=len(calldata_claim_rank)) + + Om.MSTORE(after_initcode_callata) + + Op.RETURN(0, len(after_initcode_callata)) + ) + + # Template code that will be used to deploy a large number of contracts. + initcode_address = pre.deploy_contract(code=initcode) + + # Calculate the number of contracts that can be deployed with the available gas. + gas_costs = fork.gas_costs() + intrinsic_gas_cost_calc = fork.transaction_intrinsic_cost_calculator() + loop_cost = ( + gas_costs.G_KECCAK_256 # KECCAK static cost + + math.ceil(85 / 32) * gas_costs.G_KECCAK_256_WORD # KECCAK dynamic cost for CREATE2 + + gas_costs.G_VERY_LOW * 3 # ~MSTOREs+ADDs + + gas_costs.G_COLD_ACCOUNT_ACCESS # CALL to self-destructing contract + + gas_costs.G_SELF_DESTRUCT + + 63 # ~Gluing opcodes + ) + final_storage_gas = ( + gas_costs.G_STORAGE_RESET + gas_costs.G_COLD_SLOAD + (gas_costs.G_VERY_LOW * 2) + ) + memory_expansion_cost = fork().memory_expansion_gas_calculator()(new_bytes=96) + base_costs = ( + intrinsic_gas_cost_calc() + + (gas_costs.G_VERY_LOW * 12) # 8 PUSHs + 4 MSTOREs + + final_storage_gas + + memory_expansion_cost + ) + num_contracts = num_xen # TODO: edit this to construct as much contracts as possible to + # `claimMintReward()` as the performance test. + expected_benchmark_gas_used = num_contracts * loop_cost + base_costs + + # Create a factory that deployes a new SELFDESTRUCT contract instance pre-funded depending on + # the value_bearing parameter. We use CREATE2 so the caller contract can easily reproduce + # the addresses in a loop for CALLs. + factory_code = ( + Op.EXTCODECOPY( + address=initcode_address, + dest_offset=0, + offset=0, + size=Op.EXTCODESIZE(initcode_address), + ) + + Op.MSTORE( + 0, + Op.CREATE2( + offset=0, + size=Op.EXTCODESIZE(initcode_address), + salt=Op.SLOAD(0), + ), + ) + + Op.SSTORE(0, Op.ADD(Op.SLOAD(0), 1)) + + Op.RETURN(0, 32) + ) + + factory_address = pre.deploy_contract(code=factory_code) + + factory_caller_code = Op.CALLDATALOAD(0) + While( + body=Op.POP(Op.CALL(address=factory_address)), + condition=Op.PUSH1(1) + Op.SWAP1 + Op.SUB + Op.DUP1 + Op.ISZERO + Op.ISZERO, + ) + factory_caller_address = pre.deploy_contract(code=factory_caller_code) + + contracts_deployment_tx = Transaction( + to=factory_caller_address, + gas_limit=env.gas_limit, + data=Hash(num_contracts), + sender=pre.fund_eoa(), + ) + + code = ( + # Setup memory for later CREATE2 address generation loop. + # 0xFF+[Address(20bytes)]+[seed(32bytes)]+[initcode keccak(32bytes)] + Op.MSTORE(0, factory_address) + + Op.MSTORE8(32 - 20 - 1, 0xFF) + + Op.MSTORE(32, 0) # NOTE: this memory location is used as start index of the contracts. + + Op.MSTORE(64, initcode.keccak256()) + + Op.CALLDATALOAD(0) + # Main loop + + While( + body=Op.POP(Op.CALL(address=Op.SHA3(32 - 20 - 1, 85))) + + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)), + # Loop over `CALLDATALOAD` contracts + condition=Op.PUSH1(1) + Op.SWAP1 + Op.SUB + Op.DUP1 + Op.ISZERO + Op.ISZERO, + ) + + Op.SSTORE(0, 42) # Done for successful tx execution assertion below. + ) + assert len(code) <= fork.max_code_size() + + # The 0 storage slot is initialize to avoid creation costs in SSTORE above. + code_addr = pre.deploy_contract(code=code, storage={0: 1}) + opcode_tx = Transaction( + to=code_addr, + data=Hash(num_contracts), + gas_limit=attack_gas_limit, + sender=pre.fund_eoa(), + ) + + post = { + factory_address: Account(storage={0: num_contracts}), + code_addr: Account(storage={0: 42}), # Check for successful execution. + } + deployed_contract_addresses = [] + for i in range(num_contracts): + deployed_contract_address = compute_create2_address( + address=factory_address, + salt=i, + initcode=initcode, + ) + post[deployed_contract_address] = Account(nonce=1) + deployed_contract_addresses.append(deployed_contract_address) + + setup_block = Block(txs=[contracts_deployment_tx], timestamp=timestamp) + blockchain_test( + pre=pre, + post=post, + blocks=[ + setup_block, + Block( + txs=[opcode_tx], + fee_recipient=fee_recipient, + # Set timestamp such that XEN bond matures + # See `MIN_TERM` constant in XEN source + timestamp=timestamp + 3_600 * 24, + ), + ], + exclude_full_post_state_in_output=True, + expected_benchmark_gas_used=expected_benchmark_gas_used, + ) From f3733ed77cb9edc6032c132fd2a8124b23af7c9b Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Mon, 22 Sep 2025 05:53:42 +0200 Subject: [PATCH 02/12] feat(tests): update XEN mint test [dirty] --- tests/benchmark/mainnet/test_state_xen.py | 73 +++++++++++++++-------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index a14327af908..b2f00796835 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -52,27 +52,32 @@ def test_xen_claimrank_and_mint( gas_benchmark_value: int, ): """Simple XEN scenario to claimRank(1) and claimMintReward().""" - attack_gas_limit = gas_benchmark_value - fee_recipient = pre.fund_eoa(amount=1) + # NOTE: the XEN tests are currently hardcoded against a gas limit. + # To expand this to read from `gas_benchmark_value`, we need to calculate the necessary + # amount of `num_xen` based on that gas limit (which is a complex formula as this is based + # on the gas used by the XEN contract) + attack_gas_limit = 60_000_000 # TODO: also run me for 100M. + # fee_recipient = pre.fund_eoa(amount=1) # timestamp to use for the initial block. Timestamp of later blocks are manually added/changed. timestamp = 12 # TODO: adjust this to the right amount of the actual performance test block - num_xen = 10 + num_xen = 1 # NOTE: these contracts MUST be specified for this test to work # TODO: check how/if EEST enforces this - xen_contract = pre.deploy_contract("", label="XEN_CONTRACT") + xen_contract = 0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8 # NOTE: from the test perspective this contract should not be specified # However, the XEN contract needs the Math contract. If this is not provided, the transaction # will likely revert ("fail"). This is not what we want. We want state bloat! - pre.deploy_contract("", label="MATH_CONTRACT") + # pre.deploy_contract("", label="MATH_CONTRACT") # This is after (!!) deployment (so step 2, not 1): claimMintReward() calldata_claim_mint_reward = bytes.fromhex("52c7f8dc") after_initcode_callata = Om.MSTORE(bytes.fromhex("52c7f8dc")) + Op.CALL( - address=xen_contract, args_size=len(calldata_claim_mint_reward) + address=(0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8), + args_size=len(calldata_claim_mint_reward), ) # Calldata for claimRank(1) @@ -83,7 +88,10 @@ def test_xen_claimrank_and_mint( # claimRank(1) and deposits the code to claimMintReward() if this contract is called initcode = ( Om.MSTORE(calldata_claim_rank) - + Op.CALL(address=xen_contract, args_size=len(calldata_claim_rank)) + + Op.CALL( + address=(0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8), + args_size=len(calldata_claim_rank), + ) + Om.MSTORE(after_initcode_callata) + Op.RETURN(0, len(after_initcode_callata)) ) @@ -148,7 +156,7 @@ def test_xen_claimrank_and_mint( contracts_deployment_tx = Transaction( to=factory_caller_address, - gas_limit=env.gas_limit, + gas_limit=attack_gas_limit, data=Hash(num_contracts), sender=pre.fund_eoa(), ) @@ -174,12 +182,8 @@ def test_xen_claimrank_and_mint( # The 0 storage slot is initialize to avoid creation costs in SSTORE above. code_addr = pre.deploy_contract(code=code, storage={0: 1}) - opcode_tx = Transaction( - to=code_addr, - data=Hash(num_contracts), - gas_limit=attack_gas_limit, - sender=pre.fund_eoa(), - ) + sender = pre.fund_eoa() + sender_nonce = 0 post = { factory_address: Account(storage={0: num_contracts}), @@ -195,20 +199,39 @@ def test_xen_claimrank_and_mint( post[deployed_contract_address] = Account(nonce=1) deployed_contract_addresses.append(deployed_contract_address) - setup_block = Block(txs=[contracts_deployment_tx], timestamp=timestamp) + setup_block = Block(txs=[contracts_deployment_tx]) + + blocks = [setup_block] + + # for _ in range(24*60*60): + # blocks.append(Block(txs=[Transaction(sender=sender,to=sender, nonce=sender_nonce)])) + # sender_nonce = sender_nonce + 1 + + opcode_tx = Transaction( + to=code_addr, + data=Hash(num_contracts), + gas_limit=attack_gas_limit, + sender=sender, + nonce=sender_nonce, + ) + + sender_nonce = sender_nonce + 1 + + attack_block = Block( + txs=[opcode_tx], + # NOTE: timestamp has no effect in `uv execute remote`. Forcing test to produce 24*60*60 blocks. + # It is guaranteed that the timestamp increases each block, so each block will at least move time + # by a second. + # Set timestamp such that XEN bond matures + # See `MIN_TERM` constant in XEN source + # timestamp=timestamp + 3_600 * 24, + ) + blocks.append(attack_block) + blockchain_test( pre=pre, post=post, - blocks=[ - setup_block, - Block( - txs=[opcode_tx], - fee_recipient=fee_recipient, - # Set timestamp such that XEN bond matures - # See `MIN_TERM` constant in XEN source - timestamp=timestamp + 3_600 * 24, - ), - ], + blocks=blocks, exclude_full_post_state_in_output=True, expected_benchmark_gas_used=expected_benchmark_gas_used, ) From f543b9f47df1d07e635407568548dc8be289b56c Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Mon, 22 Sep 2025 06:25:44 +0200 Subject: [PATCH 03/12] feat(tests): add XEN approval spam test --- tests/benchmark/mainnet/test_state_xen.py | 71 ++++++++++++++++++++++- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index b2f00796835..a5d9a8fabc8 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -31,6 +31,70 @@ from ethereum_test_tools import Macros as Om from ethereum_test_tools.vm.opcode import Opcodes as Op + +def test_xen_approve( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +): + """ + Uses the `approve(address,uint256)` method of XEN (ERC20) close to the maximum amount + of new slots which could be created within a single block/transaction. + """ + attack_gas_limit = ( + 60_000_000 # TODO: currently hardcoded, should be read from `gas_benchmark_value` + ) + + xen_contract = 0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8 + gas_threshold = 40_000 + + fn_signature_approve = 0x095EA7B3 # Function selector of `approve(address,uint256)` + # This code loops until there is less than threshold_gas left and reads two items from calldata: + # The first 32 bytes are interpreted as the start address to start approving for + # The second 32 bytes is the approval amount + # This can thus be used to initialize the approvals (in multiple txs) to write to the storage + # Since initializing storage (from zero to nonzero) is more expensive, this thus has + # to be done over multiple blocks/txs + # The attack block can then target all of the just initialized storage slots to edit + # (This should thus yield more dirty trie nodes than the ) + approval_loop_code = ( + Op.MSTORE(0, Hash(fn_signature_approve, left_padding=True)) + + Op.MSTORE(4 + 32, Op.CALLDATALOAD(32)) + + Op.CALLDATALOAD(0) + + While( + body=Op.MSTORE( + 4, Op.DUP1 + ) # Put a copy of the topmost stack item in memory (this is the target address) + + Op.CALL(address=xen_contract, args_offset=0, args_size=4 + 32 + 32) + + Op.ADD, # Add the status of the CALL + # (this should always be 1 unless the `gas_threshold` is too low) to the stack item + # The address and thus target storage slot changes! + condition=Op.GT(Op.GAS, gas_threshold), + ) + ) + + approval_spammer_contract = pre.deploy_contract(code=approval_loop_code) + + start_address = Hash(0) + approval_value = Hash(1) + + calldata = start_address + approval_value + + attack_tx = Transaction( + to=approval_spammer_contract, + gas_limit=attack_gas_limit, + data=calldata, + sender=pre.fund_eoa(), + ) + + blocks = [Block(txs=[attack_tx])] + + blockchain_test( + pre=pre, + post={}, # TODO: add sanity checks (succesful tx execution and no out-of-gas) + blocks=blocks, + ) + + # TODO # The current test does only claimRank(1) and then waits `SECONDS_IN_DAY = 3_600 * 24;` plus 1 # (see https://etherscan.io/token/0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8#code) and then @@ -60,7 +124,8 @@ def test_xen_claimrank_and_mint( # fee_recipient = pre.fund_eoa(amount=1) # timestamp to use for the initial block. Timestamp of later blocks are manually added/changed. - timestamp = 12 + # timestamp = 12 TODO: disabled, this is likely not part of EEST to support + # A special CL has to perform edited newPayloads such that we can edit the timestamp # TODO: adjust this to the right amount of the actual performance test block num_xen = 1 @@ -76,7 +141,7 @@ def test_xen_claimrank_and_mint( # This is after (!!) deployment (so step 2, not 1): claimMintReward() calldata_claim_mint_reward = bytes.fromhex("52c7f8dc") after_initcode_callata = Om.MSTORE(bytes.fromhex("52c7f8dc")) + Op.CALL( - address=(0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8), + address=(xen_contract), args_size=len(calldata_claim_mint_reward), ) @@ -89,7 +154,7 @@ def test_xen_claimrank_and_mint( initcode = ( Om.MSTORE(calldata_claim_rank) + Op.CALL( - address=(0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8), + address=(xen_contract), args_size=len(calldata_claim_rank), ) + Om.MSTORE(after_initcode_callata) From 8780b5b257e78ec704293c2b6acd337dccaa1d4a Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Mon, 22 Sep 2025 06:26:24 +0200 Subject: [PATCH 04/12] tmp: skip xen mint test --- tests/benchmark/mainnet/test_state_xen.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index a5d9a8fabc8..d82e4a00cd4 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -108,6 +108,7 @@ def test_xen_approve( # NOTE: deploy both XEN (0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8) # and Math (0x4bBA9B6B49f3dFA6615f079E9d66B0AA68B04A4d) in prestate for the Mainnet scenario! @pytest.mark.valid_from("Frontier") +@pytest.mark.skip(reason="TEMP: disabled test") # TODO fixme def test_xen_claimrank_and_mint( blockchain_test: BlockchainTestFiller, fork: Fork, From b04f24440527a8e6db35067522c97ae148b1980d Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Mon, 22 Sep 2025 07:00:47 +0200 Subject: [PATCH 05/12] feat(tests): fix XEN approval spam test --- tests/benchmark/mainnet/test_state_xen.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index d82e4a00cd4..b9618e8bafd 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -32,6 +32,7 @@ from ethereum_test_tools.vm.opcode import Opcodes as Op +@pytest.mark.valid_from("Frontier") def test_xen_approve( blockchain_test: BlockchainTestFiller, pre: Alloc, @@ -47,7 +48,9 @@ def test_xen_approve( xen_contract = 0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8 gas_threshold = 40_000 - fn_signature_approve = 0x095EA7B3 # Function selector of `approve(address,uint256)` + fn_signature_approve = bytes.fromhex( + "095EA7B3" + ) # Function selector of `approve(address,uint256)` # This code loops until there is less than threshold_gas left and reads two items from calldata: # The first 32 bytes are interpreted as the start address to start approving for # The second 32 bytes is the approval amount @@ -57,7 +60,7 @@ def test_xen_approve( # The attack block can then target all of the just initialized storage slots to edit # (This should thus yield more dirty trie nodes than the ) approval_loop_code = ( - Op.MSTORE(0, Hash(fn_signature_approve, left_padding=True)) + Om.MSTORE(fn_signature_approve) + Op.MSTORE(4 + 32, Op.CALLDATALOAD(32)) + Op.CALLDATALOAD(0) + While( From 3b21392cdb2ad95c7abd52b894294919e6e887f0 Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Mon, 22 Sep 2025 07:28:05 +0200 Subject: [PATCH 06/12] fix(tests): update XEN approval test to not fail-early on approving the zero address --- tests/benchmark/mainnet/test_state_xen.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index b9618e8bafd..92ecaa2d07c 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -32,6 +32,7 @@ from ethereum_test_tools.vm.opcode import Opcodes as Op +# TODO: add test which writes to already existing storage @pytest.mark.valid_from("Frontier") def test_xen_approve( blockchain_test: BlockchainTestFiller, @@ -45,6 +46,8 @@ def test_xen_approve( 60_000_000 # TODO: currently hardcoded, should be read from `gas_benchmark_value` ) + # Gas limit: 60M, 2424 SSTOREs, 300 MGas/s + xen_contract = 0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8 gas_threshold = 40_000 @@ -77,8 +80,8 @@ def test_xen_approve( approval_spammer_contract = pre.deploy_contract(code=approval_loop_code) - start_address = Hash(0) - approval_value = Hash(1) + start_address = Hash(0x01) # Approvals to the zero address are rejected, so start at 1 + approval_value = Hash(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE) calldata = start_address + approval_value From ad60f6059d1cdeb4afccec8640367dec0d85fda1 Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Mon, 22 Sep 2025 07:58:27 +0200 Subject: [PATCH 07/12] feat(tests): add approval test for existing slots --- tests/benchmark/mainnet/test_state_xen.py | 92 +++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index 92ecaa2d07c..56f1554ed05 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -101,6 +101,98 @@ def test_xen_approve( ) +@pytest.mark.valid_from("Frontier") +def test_xen_approve_existing_slots( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +): + """ + Uses the `approve(address,uint256)` method of XEN (ERC20) close to the maximum amount + of slots which could be edited (as opposed to be created) within a single block/transaction. + """ + attack_gas_limit = ( + 60_000_000 # TODO: currently hardcoded, should be read from `gas_benchmark_value` + ) + + # Gas limit: 60M, 2424 SSTOREs, 300 MGas/s + + xen_contract = 0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8 + gas_threshold = 40_000 + + fn_signature_approve = bytes.fromhex( + "095EA7B3" + ) # Function selector of `approve(address,uint256)` + # This code loops until there is less than threshold_gas left and reads two items from calldata: + # The first 32 bytes are interpreted as the start address to start approving for + # The second 32 bytes is the approval amount + # This can thus be used to initialize the approvals (in multiple txs) to write to the storage + # Since initializing storage (from zero to nonzero) is more expensive, this thus has + # to be done over multiple blocks/txs + # The attack block can then target all of the just initialized storage slots to edit + # (This should thus yield more dirty trie nodes than the ) + approval_loop_code = ( + Om.MSTORE(fn_signature_approve) + + Op.MSTORE(4 + 32, Op.CALLDATALOAD(32)) + + Op.CALLDATALOAD(0) + + While( + body=Op.MSTORE( + 4, Op.DUP1 + ) # Put a copy of the topmost stack item in memory (this is the target address) + + Op.CALL(address=xen_contract, args_offset=0, args_size=4 + 32 + 32) + + Op.ADD, # Add the status of the CALL + # (this should always be 1 unless the `gas_threshold` is too low) to the stack item + # The address and thus target storage slot changes! + condition=Op.GT(Op.GAS, gas_threshold), + ) + ) + + approval_spammer_contract = pre.deploy_contract(code=approval_loop_code) + + sender = pre.fund_eoa() + + blocks = [] + + # TODO: calculate these constants based on the gas limit of the benchmark test + start_address = 0x01 + current_address = start_address + address_incr = 2000 + + approval_value_fresh = Hash(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE) + approval_value_overwrite = Hash( + 0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDE + ) + + block_count = 10 + + for _ in range(block_count): + setup_calldata = Hash(current_address) + approval_value_fresh + setup_tx = Transaction( + to=approval_spammer_contract, + gas_limit=attack_gas_limit, + data=setup_calldata, + sender=sender, + ) + blocks.append(Block(txs=[setup_tx])) + + current_address += address_incr + + attack_calldata = Hash(start_address) + approval_value_overwrite + + attack_tx = Transaction( + to=approval_spammer_contract, + gas_limit=attack_gas_limit, + data=attack_calldata, + sender=sender, + ) + blocks.append(Block(txs=[attack_tx])) + + blockchain_test( + pre=pre, + post={}, # TODO: add sanity checks (succesful tx execution and no out-of-gas) + blocks=blocks, + ) + + # TODO # The current test does only claimRank(1) and then waits `SECONDS_IN_DAY = 3_600 * 24;` plus 1 # (see https://etherscan.io/token/0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8#code) and then From 965908deb3ed1e90ac4660e1801486fcb311bbc1 Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Mon, 29 Sep 2025 08:34:53 +0200 Subject: [PATCH 08/12] feat(tests): add more XEN tests --- tests/benchmark/mainnet/test_state_xen.py | 218 ++++++++++++++++++++-- 1 file changed, 207 insertions(+), 11 deletions(-) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index 56f1554ed05..62796112557 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -16,20 +16,20 @@ import pytest -from ethereum_test_forks import Fork +from ethereum_test_base_types.composite_types import Account +from ethereum_test_forks.helpers import Fork from ethereum_test_tools import ( - Account, Alloc, Block, BlockchainTestFiller, - Environment, Hash, Transaction, While, - compute_create2_address, ) from ethereum_test_tools import Macros as Om -from ethereum_test_tools.vm.opcode import Opcodes as Op +from ethereum_test_types.block_types import Environment +from ethereum_test_types.helpers import compute_create2_address +from ethereum_test_vm import Opcodes as Op # TODO: add test which writes to already existing storage @@ -102,7 +102,7 @@ def test_xen_approve( @pytest.mark.valid_from("Frontier") -def test_xen_approve_existing_slots( +def test_xen_approve_change_existing_slots( blockchain_test: BlockchainTestFiller, pre: Alloc, ): @@ -114,9 +114,116 @@ def test_xen_approve_existing_slots( 60_000_000 # TODO: currently hardcoded, should be read from `gas_benchmark_value` ) + # 22 Sep 10:08:22 | Processed 23366464 | 207.4 ms | slot 1,734 ms |⛽ Gas gwei: 1.00 .. 1.00 (1.00) .. 1.00 + # 22 Sep 10:08:22 | Cleared caches: Rlp + # 22 Sep 10:08:22 | Block 0.0600 ETH 59.96 MGas | 1 txs | calls 7,752 ( 0) | sload 8 | sstore 7,762 | create 0 + # 22 Sep 10:08:22 | Block throughput 289.05 MGas/s | 4.8 tps | 4.82 Blk/s | exec code cache 15,508 | new 0 | ops 1,868,419 + + xen_contract = 0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8 + gas_threshold = 40_000 + + # This test deletes 9599 storage slots from XEN + + fn_signature_approve = bytes.fromhex( + "095EA7B3" + ) # Function selector of `approve(address,uint256)` + # This code loops until there is less than threshold_gas left and reads two items from calldata: + # The first 32 bytes are interpreted as the start address to start approving for + # The second 32 bytes is the approval amount + # This can thus be used to initialize the approvals (in multiple txs) to write to the storage + # Since initializing storage (from zero to nonzero) is more expensive, this thus has + # to be done over multiple blocks/txs + # The attack block can then target all of the just initialized storage slots to edit + # (This should thus yield more dirty trie nodes than the ) + approval_loop_code = ( + Om.MSTORE(fn_signature_approve) + + Op.MSTORE(4 + 32, Op.CALLDATALOAD(32)) + + Op.CALLDATALOAD(0) + + While( + body=Op.MSTORE( + 4, Op.DUP1 + ) # Put a copy of the topmost stack item in memory (this is the target address) + + Op.CALL(address=xen_contract, args_offset=0, args_size=4 + 32 + 32) + + Op.ADD # Add the status of the CALL + # (this should always be 1 unless the `gas_threshold` is too low) to the stack item + # The address and thus target storage slot changes! + + Op.MSTORE(4 + 32, Op.SUB(Op.MLOAD(4 + 32), Op.GAS)), + condition=Op.GT(Op.GAS, gas_threshold), + ) + ) + + approval_spammer_contract = pre.deploy_contract(code=approval_loop_code) + + sender = pre.fund_eoa() + + blocks = [] + + # TODO: calculate these constants based on the gas limit of the benchmark test + start_address = 0x01 # XEN blocks approving the zero address + current_address = start_address + address_incr = 2000 + + approval_value_fresh = Hash(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE) + approval_value_overwrite = Hash(0) + + block_count = 10 + + for _ in range(block_count): + setup_calldata = Hash(current_address) + approval_value_fresh + setup_tx = Transaction( + to=approval_spammer_contract, + gas_limit=attack_gas_limit, + data=setup_calldata, + sender=sender, + max_priority_fee_per_gas=100, + max_fee_per_gas=10000, + ) + blocks.append(Block(txs=[setup_tx])) + + current_address += address_incr + + attack_calldata = Hash(start_address) + approval_value_overwrite + + attack_tx = Transaction( + to=approval_spammer_contract, + gas_limit=attack_gas_limit, + max_priority_fee_per_gas=100, + max_fee_per_gas=10000, + data=attack_calldata, + sender=sender, + ) + blocks.append(Block(txs=[attack_tx])) + + blockchain_test( + pre=pre, + post={}, # TODO: add sanity checks (succesful tx execution and no out-of-gas) + blocks=blocks, + ) + + +# TODO split this code in all situations: 0->1, 1->2, 1->0 +@pytest.mark.valid_from("Frontier") +def test_xen_approve_delete_existing_slots( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +): + """ + Uses the `approve(address,uint256)` method of XEN (ERC20) close to the maximum amount + of slots which could be edited (as opposed to be created) within a single block/transaction. + """ + attack_gas_limit = ( + 60_000_000 # TODO: currently hardcoded, should be read from `gas_benchmark_value` + ) + + # 22 Sep 10:08:22 | Processed 23366464 | 207.4 ms | slot 1,734 ms |⛽ Gas gwei: 1.00 .. 1.00 (1.00) .. 1.00 + # 22 Sep 10:08:22 | Cleared caches: Rlp + # 22 Sep 10:08:22 | Block 0.0600 ETH 59.96 MGas | 1 txs | calls 7,752 ( 0) | sload 8 | sstore 7,762 | create 0 + # 22 Sep 10:08:22 | Block throughput 289.05 MGas/s | 4.8 tps | 4.82 Blk/s | exec code cache 15,508 | new 0 | ops 1,868,419 + # Gas limit: 60M, 2424 SSTOREs, 300 MGas/s xen_contract = 0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8 + usdt_contract = 0xDAC17F958D2EE523A2206206994597C13D831EC7 # Used in intermediate blocks to attempt to bust te cache gas_threshold = 40_000 fn_signature_approve = bytes.fromhex( @@ -142,25 +249,53 @@ def test_xen_approve_existing_slots( + Op.ADD, # Add the status of the CALL # (this should always be 1 unless the `gas_threshold` is too low) to the stack item # The address and thus target storage slot changes! + # + Op.MSTORE(4 + 32, Op.SUB(Op.MLOAD(4 + 34), Op.GAS)), condition=Op.GT(Op.GAS, gas_threshold), ) ) approval_spammer_contract = pre.deploy_contract(code=approval_loop_code) + usdt_approve_spammer_code = ( + Om.MSTORE(fn_signature_approve) + + Op.MSTORE(4 + 32, 1) + + Op.SLOAD(0) + + While( + body=Op.MSTORE( + 4, Op.DUP1 + ) # Put a copy of the topmost stack item in memory (this is the target address) + + Op.CALL(address=usdt_contract, args_offset=0, args_size=4 + 32 + 32) + + Op.ADD, # Add the status of the CALL + # (this should always be 1 unless the `gas_threshold` is too low) to the stack item + # The address and thus target storage slot changes! + # + Op.MSTORE(4 + 32, Op.SUB(Op.MLOAD(4 + 34), Op.GAS)), + condition=Op.GT(Op.GAS, gas_threshold), + ) + + Op.PUSH1(0) + + Op.SSTORE + ) + # Set storage to value 1 to avoid paying 20k on the update + usdt_approve_spammer_contract = pre.deploy_contract( + code=usdt_approve_spammer_code, storage={0: 1} + ) + sender = pre.fund_eoa() + sender2 = pre.fund_eoa() # More senders to get more chance to get a semi-full block + sender3 = ( + pre.fund_eoa() + ) # If done from one sender, Nethermind's block builder only includes 1 tx + sender4 = pre.fund_eoa() + sender5 = pre.fund_eoa() blocks = [] # TODO: calculate these constants based on the gas limit of the benchmark test - start_address = 0x01 + start_address = 0x01 # Start at address 1, address 0 cannot be approved current_address = start_address address_incr = 2000 approval_value_fresh = Hash(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE) - approval_value_overwrite = Hash( - 0xDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDE - ) + approval_value_overwrite = Hash(0) block_count = 10 @@ -171,20 +306,81 @@ def test_xen_approve_existing_slots( gas_limit=attack_gas_limit, data=setup_calldata, sender=sender, + max_priority_fee_per_gas=100, + max_fee_per_gas=10000, ) blocks.append(Block(txs=[setup_tx])) current_address += address_incr + spam_count = 10 + + for _ in range(spam_count): + # NOTE: USDC does not allow changing the approval value. It first has to be + # set to zero before it changes. We therefore flood USDC with approvals in an + # attempt to bust the cache + spam_tx = Transaction( + to=usdt_approve_spammer_contract, + gas_limit=attack_gas_limit, + sender=sender, + max_priority_fee_per_gas=100, + max_fee_per_gas=10000, + ) + blocks.append(Block(txs=[spam_tx])) + attack_calldata = Hash(start_address) + approval_value_overwrite attack_tx = Transaction( to=approval_spammer_contract, gas_limit=attack_gas_limit, + max_priority_fee_per_gas=100, + max_fee_per_gas=10000, data=attack_calldata, sender=sender, ) - blocks.append(Block(txs=[attack_tx])) + # Take into account the max refunds (which will be awarded here) + # The previous tx will also not completely use all gas since it jumps out of the loop early + # to avoid that the whole tx OOGs + # TODO: make this attack gas limit dependent + # It should be sufficient to assume full refund and to send the whole block as gas limit initially + # The next tx gas limit is thus (if refund is maximally applied) 20% of the original + # Repeat this until the intrinsic costs cannot be paid + attack_tx_2 = Transaction( + to=approval_spammer_contract, + gas_limit=attack_gas_limit // 5, + max_priority_fee_per_gas=90, + max_fee_per_gas=9000, + data=Hash(8000) + approval_value_overwrite, + sender=sender2, + ) + attack_tx_3 = Transaction( + to=approval_spammer_contract, + gas_limit=attack_gas_limit // (5 * 5), + max_priority_fee_per_gas=80, + max_fee_per_gas=8000, + data=Hash(12000) + approval_value_overwrite, + sender=sender3, + ) + attack_tx_4 = Transaction( + to=approval_spammer_contract, + gas_limit=attack_gas_limit // (5 * 5 * 5), + max_priority_fee_per_gas=80, + max_fee_per_gas=8000, + data=Hash(16000) + approval_value_overwrite, + sender=sender4, + ) + attack_tx_5 = Transaction( + to=approval_spammer_contract, + gas_limit=attack_gas_limit // (5 * 5 * 5 * 5), + max_priority_fee_per_gas=80, + max_fee_per_gas=8000, + data=Hash(18000) + approval_value_overwrite, + sender=sender5, + ) + + blocks.append( + Block(txs=[attack_tx, attack_tx_2, attack_tx_3, attack_tx_4, attack_tx_5]) + ) # , #attack_tx_2, attack_tx_3])) blockchain_test( pre=pre, From 21fdf9c6c1ce5fc8d347881e767d99e20c505a71 Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Thu, 2 Oct 2025 20:19:06 +0200 Subject: [PATCH 09/12] update xen tests --- tests/benchmark/mainnet/test_state_xen.py | 131 +++++++++------------- 1 file changed, 53 insertions(+), 78 deletions(-) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index 62796112557..277a7bc239f 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -12,23 +12,20 @@ However, with enough funds (to bloat a contract state), this is thus not the worst scenario. """ -import math - import pytest -from ethereum_test_base_types.composite_types import Account -from ethereum_test_forks.helpers import Fork +from ethereum_test_forks import Fork from ethereum_test_tools import ( + Account, Alloc, Block, BlockchainTestFiller, + Environment, Hash, Transaction, While, ) from ethereum_test_tools import Macros as Om -from ethereum_test_types.block_types import Environment -from ethereum_test_types.helpers import compute_create2_address from ethereum_test_vm import Opcodes as Op @@ -98,6 +95,7 @@ def test_xen_approve( pre=pre, post={}, # TODO: add sanity checks (succesful tx execution and no out-of-gas) blocks=blocks, + skip_gas_used_validation=True, ) @@ -182,6 +180,8 @@ def test_xen_approve_change_existing_slots( current_address += address_incr + # TODO: insert unrelated spam to USDT to bust cache + attack_calldata = Hash(start_address) + approval_value_overwrite attack_tx = Transaction( @@ -198,15 +198,13 @@ def test_xen_approve_change_existing_slots( pre=pre, post={}, # TODO: add sanity checks (succesful tx execution and no out-of-gas) blocks=blocks, + skip_gas_used_validation=True, ) # TODO split this code in all situations: 0->1, 1->2, 1->0 @pytest.mark.valid_from("Frontier") -def test_xen_approve_delete_existing_slots( - blockchain_test: BlockchainTestFiller, - pre: Alloc, -): +def test_xen_approve_delete_existing_slots(blockchain_test: BlockchainTestFiller, pre: Alloc): """ Uses the `approve(address,uint256)` method of XEN (ERC20) close to the maximum amount of slots which could be edited (as opposed to be created) within a single block/transaction. @@ -297,7 +295,7 @@ def test_xen_approve_delete_existing_slots( approval_value_fresh = Hash(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE) approval_value_overwrite = Hash(0) - block_count = 10 + block_count = 100 for _ in range(block_count): setup_calldata = Hash(current_address) + approval_value_fresh @@ -386,6 +384,7 @@ def test_xen_approve_delete_existing_slots( pre=pre, post={}, # TODO: add sanity checks (succesful tx execution and no out-of-gas) blocks=blocks, + skip_gas_used_validation=True, ) @@ -401,8 +400,9 @@ def test_xen_approve_delete_existing_slots( # TODO: set correct fork, XEN might reject on historical forks due to e.g. non-existent opcodes # NOTE: deploy both XEN (0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8) # and Math (0x4bBA9B6B49f3dFA6615f079E9d66B0AA68B04A4d) in prestate for the Mainnet scenario! +# NOTE: this requires the timestamp hack, where each new payload moves by at least 24*60*60+1 +# seconds (fix me later if we have the tooling for this) @pytest.mark.valid_from("Frontier") -@pytest.mark.skip(reason="TEMP: disabled test") # TODO fixme def test_xen_claimrank_and_mint( blockchain_test: BlockchainTestFiller, fork: Fork, @@ -422,9 +422,6 @@ def test_xen_claimrank_and_mint( # timestamp = 12 TODO: disabled, this is likely not part of EEST to support # A special CL has to perform edited newPayloads such that we can edit the timestamp - # TODO: adjust this to the right amount of the actual performance test block - num_xen = 1 - # NOTE: these contracts MUST be specified for this test to work # TODO: check how/if EEST enforces this xen_contract = 0x06450DEE7FD2FB8E39061434BABCFC05599A6FB8 @@ -435,58 +432,40 @@ def test_xen_claimrank_and_mint( # This is after (!!) deployment (so step 2, not 1): claimMintReward() calldata_claim_mint_reward = bytes.fromhex("52c7f8dc") - after_initcode_callata = Om.MSTORE(bytes.fromhex("52c7f8dc")) + Op.CALL( - address=(xen_contract), - args_size=len(calldata_claim_mint_reward), - ) - # Calldata for claimRank(1) calldata_claim_rank = bytes.fromhex( "9ff054df0000000000000000000000000000000000000000000000000000000000000001" ) + after_initcode_callata = ( + Om.MSTORE(bytes.fromhex("52c7f8dc"), offset=0) + + Op.CALL( + address=(xen_contract), + args_size=len(calldata_claim_mint_reward), + ) + + Om.MSTORE(calldata_claim_rank, offset=0) + + Op.CALL( + address=(xen_contract), + args_size=len(calldata_claim_rank), + ) + ) # claimRank(1) and deposits the code to claimMintReward() if this contract is called + # + claimRank(1) again initcode = ( - Om.MSTORE(calldata_claim_rank) + Om.MSTORE(calldata_claim_rank, offset=0) + Op.CALL( address=(xen_contract), args_size=len(calldata_claim_rank), ) - + Om.MSTORE(after_initcode_callata) + + Om.MSTORE(after_initcode_callata, offset=0) + Op.RETURN(0, len(after_initcode_callata)) ) # Template code that will be used to deploy a large number of contracts. initcode_address = pre.deploy_contract(code=initcode) - # Calculate the number of contracts that can be deployed with the available gas. - gas_costs = fork.gas_costs() - intrinsic_gas_cost_calc = fork.transaction_intrinsic_cost_calculator() - loop_cost = ( - gas_costs.G_KECCAK_256 # KECCAK static cost - + math.ceil(85 / 32) * gas_costs.G_KECCAK_256_WORD # KECCAK dynamic cost for CREATE2 - + gas_costs.G_VERY_LOW * 3 # ~MSTOREs+ADDs - + gas_costs.G_COLD_ACCOUNT_ACCESS # CALL to self-destructing contract - + gas_costs.G_SELF_DESTRUCT - + 63 # ~Gluing opcodes - ) - final_storage_gas = ( - gas_costs.G_STORAGE_RESET + gas_costs.G_COLD_SLOAD + (gas_costs.G_VERY_LOW * 2) - ) - memory_expansion_cost = fork().memory_expansion_gas_calculator()(new_bytes=96) - base_costs = ( - intrinsic_gas_cost_calc() - + (gas_costs.G_VERY_LOW * 12) # 8 PUSHs + 4 MSTOREs - + final_storage_gas - + memory_expansion_cost - ) - num_contracts = num_xen # TODO: edit this to construct as much contracts as possible to # `claimMintReward()` as the performance test. - expected_benchmark_gas_used = num_contracts * loop_cost + base_costs - # Create a factory that deployes a new SELFDESTRUCT contract instance pre-funded depending on - # the value_bearing parameter. We use CREATE2 so the caller contract can easily reproduce - # the addresses in a loop for CALLs. factory_code = ( Op.EXTCODECOPY( address=initcode_address, @@ -508,18 +487,17 @@ def test_xen_claimrank_and_mint( factory_address = pre.deploy_contract(code=factory_code) - factory_caller_code = Op.CALLDATALOAD(0) + While( + factory_gas_threshold = 400_000 + + factory_caller_code = While( body=Op.POP(Op.CALL(address=factory_address)), - condition=Op.PUSH1(1) + Op.SWAP1 + Op.SUB + Op.DUP1 + Op.ISZERO + Op.ISZERO, + condition=Op.GT(Op.GAS, factory_gas_threshold), ) factory_caller_address = pre.deploy_contract(code=factory_caller_code) - contracts_deployment_tx = Transaction( - to=factory_caller_address, - gas_limit=attack_gas_limit, - data=Hash(num_contracts), - sender=pre.fund_eoa(), - ) + sender = pre.fund_eoa() + + gas_threshold = 400_000 code = ( # Setup memory for later CREATE2 address generation loop. @@ -528,13 +506,12 @@ def test_xen_claimrank_and_mint( + Op.MSTORE8(32 - 20 - 1, 0xFF) + Op.MSTORE(32, 0) # NOTE: this memory location is used as start index of the contracts. + Op.MSTORE(64, initcode.keccak256()) - + Op.CALLDATALOAD(0) # Main loop + While( body=Op.POP(Op.CALL(address=Op.SHA3(32 - 20 - 1, 85))) + Op.MSTORE(32, Op.ADD(Op.MLOAD(32), 1)), # Loop over `CALLDATALOAD` contracts - condition=Op.PUSH1(1) + Op.SWAP1 + Op.SUB + Op.DUP1 + Op.ISZERO + Op.ISZERO, + condition=Op.GT(Op.GAS, gas_threshold), ) + Op.SSTORE(0, 42) # Done for successful tx execution assertion below. ) @@ -542,26 +519,28 @@ def test_xen_claimrank_and_mint( # The 0 storage slot is initialize to avoid creation costs in SSTORE above. code_addr = pre.deploy_contract(code=code, storage={0: 1}) - sender = pre.fund_eoa() - sender_nonce = 0 post = { - factory_address: Account(storage={0: num_contracts}), code_addr: Account(storage={0: 42}), # Check for successful execution. } - deployed_contract_addresses = [] - for i in range(num_contracts): - deployed_contract_address = compute_create2_address( - address=factory_address, - salt=i, - initcode=initcode, - ) - post[deployed_contract_address] = Account(nonce=1) - deployed_contract_addresses.append(deployed_contract_address) + # deployed_contract_addresses = [] + # for i in range(num_contracts): + # deployed_contract_address = compute_create2_address( + # address=factory_address, + # salt=i, + ## initcode=initcode, + # ) + # post[deployed_contract_address] = Account(nonce=1) + # deployed_contract_addresses.append(deployed_contract_address) - setup_block = Block(txs=[contracts_deployment_tx]) - - blocks = [setup_block] + blocks = [] + for _ in range(20): + contracts_deployment_tx = Transaction( + to=factory_caller_address, + gas_limit=attack_gas_limit, + sender=sender, + ) + blocks.append(Block(txs=[contracts_deployment_tx])) # for _ in range(24*60*60): # blocks.append(Block(txs=[Transaction(sender=sender,to=sender, nonce=sender_nonce)])) @@ -569,14 +548,10 @@ def test_xen_claimrank_and_mint( opcode_tx = Transaction( to=code_addr, - data=Hash(num_contracts), gas_limit=attack_gas_limit, sender=sender, - nonce=sender_nonce, ) - sender_nonce = sender_nonce + 1 - attack_block = Block( txs=[opcode_tx], # NOTE: timestamp has no effect in `uv execute remote`. Forcing test to produce 24*60*60 blocks. @@ -593,5 +568,5 @@ def test_xen_claimrank_and_mint( post=post, blocks=blocks, exclude_full_post_state_in_output=True, - expected_benchmark_gas_used=expected_benchmark_gas_used, + skip_gas_used_validation=True, ) From c1796f517adc1ee228dcdb1b82bd1c64b532a10d Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Thu, 2 Oct 2025 23:47:45 +0200 Subject: [PATCH 10/12] update xen claimrank test --- tests/benchmark/mainnet/test_state_xen.py | 44 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index 277a7bc239f..bd3d793dcfd 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -319,7 +319,7 @@ def test_xen_approve_delete_existing_slots(blockchain_test: BlockchainTestFiller # attempt to bust the cache spam_tx = Transaction( to=usdt_approve_spammer_contract, - gas_limit=attack_gas_limit, + gas_limit=attack_gas_limit // 2, sender=sender, max_priority_fee_per_gas=100, max_fee_per_gas=10000, @@ -534,7 +534,7 @@ def test_xen_claimrank_and_mint( # deployed_contract_addresses.append(deployed_contract_address) blocks = [] - for _ in range(20): + for _ in range(10): # Check how much of these we need contracts_deployment_tx = Transaction( to=factory_caller_address, gas_limit=attack_gas_limit, @@ -542,6 +542,46 @@ def test_xen_claimrank_and_mint( ) blocks.append(Block(txs=[contracts_deployment_tx])) + fn_signature_approve = bytes.fromhex("095EA7B3") + usdt_contract = 0xDAC17F958D2EE523A2206206994597C13D831EC7 # Used in intermediate blocks to attempt to bust te cache + usdt_approve_spammer_code = ( + Om.MSTORE(fn_signature_approve) + + Op.MSTORE(4 + 32, 1) + + Op.SLOAD(0) + + While( + body=Op.MSTORE( + 4, Op.DUP1 + ) # Put a copy of the topmost stack item in memory (this is the target address) + + Op.CALL(address=usdt_contract, args_offset=0, args_size=4 + 32 + 32) + + Op.ADD, # Add the status of the CALL + # (this should always be 1 unless the `gas_threshold` is too low) to the stack item + # The address and thus target storage slot changes! + # + Op.MSTORE(4 + 32, Op.SUB(Op.MLOAD(4 + 34), Op.GAS)), + condition=Op.GT(Op.GAS, gas_threshold), + ) + + Op.PUSH1(0) + + Op.SSTORE + ) + # Set storage to value 1 to avoid paying 20k on the update + usdt_approve_spammer_contract = pre.deploy_contract( + code=usdt_approve_spammer_code, storage={0: 1} + ) + + spam_count = 100 + + for _ in range(spam_count): + # NOTE: USDC does not allow changing the approval value. It first has to be + # set to zero before it changes. We therefore flood USDC with approvals in an + # attempt to bust the cache + spam_tx = Transaction( + to=usdt_approve_spammer_contract, + gas_limit=attack_gas_limit // 2, + sender=sender, + max_priority_fee_per_gas=100, + max_fee_per_gas=10000, + ) + blocks.append(Block(txs=[spam_tx])) + # for _ in range(24*60*60): # blocks.append(Block(txs=[Transaction(sender=sender,to=sender, nonce=sender_nonce)])) # sender_nonce = sender_nonce + 1 From 23f3ad140c238edd2418c7a680d2e5984a10aeb4 Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Fri, 3 Oct 2025 00:24:25 +0200 Subject: [PATCH 11/12] update spam numbers --- tests/benchmark/mainnet/test_state_xen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index bd3d793dcfd..53f165d2380 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -295,7 +295,7 @@ def test_xen_approve_delete_existing_slots(blockchain_test: BlockchainTestFiller approval_value_fresh = Hash(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE) approval_value_overwrite = Hash(0) - block_count = 100 + block_count = 10 for _ in range(block_count): setup_calldata = Hash(current_address) + approval_value_fresh @@ -311,7 +311,7 @@ def test_xen_approve_delete_existing_slots(blockchain_test: BlockchainTestFiller current_address += address_incr - spam_count = 10 + spam_count = 1000 for _ in range(spam_count): # NOTE: USDC does not allow changing the approval value. It first has to be From 978f168e1984d7eb6253fdfa3047a78924daac9c Mon Sep 17 00:00:00 2001 From: Jochem Brouwer Date: Fri, 3 Oct 2025 01:05:28 +0200 Subject: [PATCH 12/12] decrease spam count --- tests/benchmark/mainnet/test_state_xen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/benchmark/mainnet/test_state_xen.py b/tests/benchmark/mainnet/test_state_xen.py index 53f165d2380..1af0fa9d3f0 100644 --- a/tests/benchmark/mainnet/test_state_xen.py +++ b/tests/benchmark/mainnet/test_state_xen.py @@ -311,7 +311,7 @@ def test_xen_approve_delete_existing_slots(blockchain_test: BlockchainTestFiller current_address += address_incr - spam_count = 1000 + spam_count = 200 for _ in range(spam_count): # NOTE: USDC does not allow changing the approval value. It first has to be