-
Notifications
You must be signed in to change notification settings - Fork 416
feat(tests): add worst-case BAL read test #2033
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: forks/amsterdam
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,262 @@ | ||
| """ | ||
| Tests for EIP-7928 BAL with computation followed by SLOADs. | ||
|
|
||
| Creates transactions that first perform computation, then cold SLOADs. | ||
| The compute_percent parameter controls the gas split between phases. | ||
| """ | ||
|
|
||
| import pytest | ||
|
|
||
| from execution_testing import ( | ||
| Account, | ||
| Alloc, | ||
| BalAccountExpectation, | ||
| BalNonceChange, | ||
| Block, | ||
| BlockAccessListExpectation, | ||
| BlockchainTestFiller, | ||
| Bytecode, | ||
| Environment, | ||
| Fork, | ||
| Op, | ||
| Storage, | ||
| Transaction, | ||
| ) | ||
|
|
||
| from .spec import ref_spec_7928 | ||
|
|
||
| REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path | ||
| REFERENCE_SPEC_VERSION = ref_spec_7928.version | ||
|
|
||
| pytestmark = pytest.mark.valid_from("Amsterdam") | ||
|
|
||
| GAS_PER_COMPUTE_ITERATION = 60 | ||
| GAS_PER_SLOAD_ITERATION = 2_200 | ||
|
|
||
|
|
||
| def create_compute_then_sload_contract() -> Bytecode: | ||
| """ | ||
| Create bytecode that first computes, then SLOADs. | ||
|
|
||
| Calldata: computation_iterations (32) + start_slot (32) + sload_count (32) | ||
| Phase 1: Computation loop (accumulator = accumulator * 3 + 7) | ||
| Phase 2: SLOAD loop (sequential slots) | ||
| """ | ||
| compute_loop_start = 5 | ||
| compute_end = 28 | ||
| sload_loop_start = 38 | ||
| sload_end = 61 | ||
|
|
||
| code = ( | ||
| Op.PUSH1(0x00) | ||
| + Op.CALLDATALOAD | ||
| + Op.PUSH1(0x01) | ||
| + Op.JUMPDEST # compute_loop_start | ||
| + Op.SWAP1 | ||
| + Op.DUP1 | ||
| + Op.ISZERO | ||
| + Op.PUSH2(compute_end) | ||
| + Op.JUMPI | ||
| + Op.PUSH1(0x01) | ||
| + Op.SWAP1 | ||
| + Op.SUB | ||
| + Op.SWAP1 | ||
| + Op.PUSH1(0x03) | ||
| + Op.MUL | ||
| + Op.PUSH1(0x07) | ||
| + Op.ADD | ||
| + Op.PUSH2(compute_loop_start) | ||
| + Op.JUMP | ||
| + Op.JUMPDEST # compute_end | ||
| + Op.POP | ||
| + Op.POP | ||
| + Op.PUSH1(0x20) | ||
| + Op.CALLDATALOAD | ||
| + Op.PUSH1(0x40) | ||
| + Op.CALLDATALOAD | ||
| + Op.SWAP1 | ||
| + Op.JUMPDEST # sload_loop_start | ||
| + Op.DUP2 | ||
| + Op.ISZERO | ||
| + Op.PUSH2(sload_end) | ||
| + Op.JUMPI | ||
| + Op.DUP1 | ||
| + Op.SLOAD | ||
| + Op.POP | ||
| + Op.PUSH1(0x01) | ||
| + Op.ADD | ||
| + Op.SWAP1 | ||
| + Op.PUSH1(0x01) | ||
| + Op.SWAP1 | ||
| + Op.SUB | ||
| + Op.SWAP1 | ||
| + Op.PUSH2(sload_loop_start) | ||
| + Op.JUMP | ||
| + Op.JUMPDEST # sload_end | ||
| + Op.STOP | ||
| ) | ||
| return code | ||
|
|
||
|
|
||
| def calculate_test_parameters( | ||
| block_gas_limit: int, fork: Fork, compute_fraction: float | ||
| ) -> tuple[int, int, int, int]: | ||
| """Calculate (num_transactions, compute_iters, sload_count, total_slots).""" | ||
| gas_costs = fork.gas_costs() | ||
| max_tx_gas = fork.transaction_gas_limit_cap() | ||
| assert max_tx_gas is not None | ||
|
|
||
| num_transactions = block_gas_limit // max_tx_gas | ||
| intrinsic_overhead = gas_costs.G_TRANSACTION + 600 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 600 magic number? Is this enough to cover the calldata? I think we don't have to calculate this, we can construct the transaction and then calculate the intrinsic fee from there. |
||
| available_gas = max_tx_gas - intrinsic_overhead | ||
|
|
||
| compute_gas = int(available_gas * compute_fraction) | ||
| sload_gas = available_gas - compute_gas | ||
|
|
||
| compute_iters_per_tx = compute_gas // GAS_PER_COMPUTE_ITERATION | ||
| sload_count_per_tx = sload_gas // GAS_PER_SLOAD_ITERATION | ||
| total_slots = num_transactions * sload_count_per_tx | ||
|
|
||
| return num_transactions, compute_iters_per_tx, sload_count_per_tx, total_slots | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "compute_percent", | ||
| [5, 10, 25, 50], | ||
| ids=lambda p: f"compute_{p}pct", | ||
| ) | ||
| def test_bal_compute_then_sload( | ||
| pre: Alloc, | ||
| blockchain_test: BlockchainTestFiller, | ||
| fork: Fork, | ||
| compute_percent: int, | ||
| ) -> None: | ||
| """Test BAL with computation phase followed by SLOAD phase.""" | ||
| env = Environment() | ||
| block_gas_limit = int(env.gas_limit) | ||
| max_tx_gas = fork.transaction_gas_limit_cap() | ||
| compute_fraction = compute_percent / 100.0 | ||
|
|
||
| ( | ||
| num_transactions, | ||
| compute_iters_per_tx, | ||
| sload_count_per_tx, | ||
| total_slots, | ||
| ) = calculate_test_parameters(block_gas_limit, fork, compute_fraction) | ||
|
|
||
| storage = Storage({i: i + 1 for i in range(total_slots)}) # type: ignore | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doesn't this setup the storage up in a way 0 -> 1 -> 2? We want to read backwards now right? |
||
|
|
||
| contract_code = create_compute_then_sload_contract() | ||
| contract = pre.deploy_contract(code=contract_code, storage=storage) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if this works if we deploy a contract with a rather large storage (which cannot be deployed in one transaction) |
||
|
|
||
| senders = [pre.fund_eoa() for _ in range(num_transactions)] | ||
| transactions = [] | ||
|
|
||
| for tx_idx in range(num_transactions): | ||
| start_slot = tx_idx * sload_count_per_tx | ||
|
|
||
| calldata = ( | ||
| compute_iters_per_tx.to_bytes(32, "big") | ||
| + start_slot.to_bytes(32, "big") | ||
| + sload_count_per_tx.to_bytes(32, "big") | ||
| ) | ||
| tx = Transaction( | ||
| sender=senders[tx_idx], | ||
| to=contract, | ||
| gas_limit=max_tx_gas, | ||
| data=calldata, | ||
| ) | ||
| transactions.append(tx) | ||
|
|
||
| all_slots_read = list(range(total_slots)) | ||
| account_expectations = { | ||
| contract: BalAccountExpectation(storage_reads=all_slots_read), | ||
| } | ||
| for tx_idx, sender in enumerate(senders): | ||
| account_expectations[sender] = BalAccountExpectation( | ||
| nonce_changes=[ | ||
| BalNonceChange(block_access_index=tx_idx + 1, post_nonce=1) | ||
| ], | ||
| ) | ||
|
|
||
| block = Block( | ||
| txs=transactions, | ||
| expected_block_access_list=BlockAccessListExpectation( | ||
| account_expectations=account_expectations | ||
| ), | ||
| ) | ||
|
|
||
| post = {contract: Account(storage=storage)} | ||
| for sender in senders: | ||
| post[sender] = Account(nonce=1) | ||
|
|
||
| blockchain_test(pre=pre, blocks=[block], post=post) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "compute_percent", | ||
| [10, 50], | ||
| ids=lambda p: f"compute_{p}pct", | ||
| ) | ||
| def test_bal_compute_then_sload_simple( | ||
| pre: Alloc, | ||
| blockchain_test: BlockchainTestFiller, | ||
| fork: Fork, | ||
| compute_percent: int, | ||
| ) -> None: | ||
| """Simple validation test with configurable computation and 20 SLOADs.""" | ||
| total_slots = 20 | ||
| num_transactions = 2 | ||
| sload_count_per_tx = total_slots // num_transactions | ||
|
|
||
| gas_budget = 400_000 | ||
| compute_gas = int(gas_budget * compute_percent / 100.0) | ||
| compute_iterations = compute_gas // GAS_PER_COMPUTE_ITERATION | ||
|
|
||
| storage = Storage({i: i + 1 for i in range(total_slots)}) # type: ignore | ||
|
|
||
| contract_code = create_compute_then_sload_contract() | ||
| contract = pre.deploy_contract(code=contract_code, storage=storage) | ||
|
|
||
| senders = [pre.fund_eoa() for _ in range(num_transactions)] | ||
| transactions = [] | ||
|
|
||
| for tx_idx in range(num_transactions): | ||
| start_slot = tx_idx * sload_count_per_tx | ||
|
|
||
| calldata = ( | ||
| compute_iterations.to_bytes(32, "big") | ||
| + start_slot.to_bytes(32, "big") | ||
| + sload_count_per_tx.to_bytes(32, "big") | ||
| ) | ||
| tx = Transaction( | ||
| sender=senders[tx_idx], | ||
| to=contract, | ||
| gas_limit=500_000, | ||
| data=calldata, | ||
| ) | ||
| transactions.append(tx) | ||
|
|
||
| all_slots_read = list(range(total_slots)) | ||
| account_expectations = { | ||
| contract: BalAccountExpectation(storage_reads=all_slots_read), | ||
| } | ||
| for tx_idx, sender in enumerate(senders): | ||
| account_expectations[sender] = BalAccountExpectation( | ||
| nonce_changes=[ | ||
| BalNonceChange(block_access_index=tx_idx + 1, post_nonce=1) | ||
| ], | ||
| ) | ||
|
|
||
| block = Block( | ||
| txs=transactions, | ||
| expected_block_access_list=BlockAccessListExpectation( | ||
| account_expectations=account_expectations | ||
| ), | ||
| ) | ||
|
|
||
| post = {contract: Account(storage=storage)} | ||
| for sender in senders: | ||
| post[sender] = Account(nonce=1) | ||
|
|
||
| blockchain_test(pre=pre, blocks=[block], post=post) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Putting these pointers in the calldata means in this test that the transactions can be executed in parallel in the block without the need of BALs. To solve this (to force transactions being executed sequentially without BALs or to make parallel execution available if a BAL is provided) we need to make the transactions depend on the previous transaction.
The simplest way to do this is to just use the EVM storage to store the current start slot in (initialize this slot to 0xffff..ff so we start at the highest slot). For the SLOAD loop, use a
Whileloop and the condition is a simple check to see if gasleft is enough to perform one more loop and the termination logic (which stores the current slot into the storage).Can also be added to the computation loop. We can hardcode the "gasleft" there which is the threshold to continue the loop. For instance if the compute loop is 50%, then we just take 50% of the tx gas limit and use that as
GASLEFT>xasxvalue for the condition.Note that if we use the gas threshold in the EVM we do not have to manually calculate gas per loop. We also ensure that transactions depend on each other, since the second transaction has to start at the slot where the first one ended (cannot be executed in parallel without BALs). The EVM loss in cycles is nonzero, but it is negligible compared to the total SLOAD count.