Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Member

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 While loop 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 > x as x value 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.

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
Copy link
Member

Choose a reason for hiding this comment

The 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
Copy link
Member

Choose a reason for hiding this comment

The 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)
Copy link
Member

Choose a reason for hiding this comment

The 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)
Loading
Loading