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
2 changes: 2 additions & 0 deletions docs/filling_tests/fill_stateful.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Optional:
- `--output PATH` — default `./fixtures`.
- `--clean` — wipe the output dir before filling.
- `--extract-opcode-count` — after building each block, trace it via `debug_traceBlockByHash` (a JS opcode-counting tracer) and record per-opcode execution counts (execution-phase blocks only) in the fixture's `_info.metadata.opcode_counts` — an array with one entry per `engineNewPayloads` block (`opcode_counts[i]` is the count for `engineNewPayloads[i]`, or `null` if its trace was unavailable), so multi-block benchmarks keep per-payload granularity. When a benchmark test declares a target opcode count (`fixed_opcode_count`/`expected_opcode_count`), the live-client count is verified against it and the fill fails on >5% divergence. Requires the `debug` namespace with JS tracer support. Adds a full re-execution trace per block, so it is slow and opt-in.
- `--no-reset-between-tests` — skip the between-test rewind to `start_block`, so each test builds on the state the previous one left behind and the client head accumulates upward. Used to pre-populate a datadir (e.g. deploy setup contracts) whose persisted state a later run builds on. The written fixtures are unchanged — each still records its own `start_block` — so a fixture produced this way is only valid to replay against a client already at that `start_block`; do not mix accumulate-state fills and normal single-anchor fills in one output dir.

## Output layout

Expand Down Expand Up @@ -198,6 +199,7 @@ Both backends satisfy `FillerBackend` (`client_clis/filler_backend.py`). `Client
|---|---|---|
| `--snapshot-block` | `fill-stateful` | Anchor by 32-byte hash (reorg-safe) or block number; defaults to `latest`. |
| `--rpc-seed-key` | `fill-stateful` | Pin the seed EOA; otherwise generated + funded via CL withdrawal. |
| `--no-reset-between-tests` | `fill-stateful` | Skip the between-test rewind so tests accumulate state on the live client (pre-populate a datadir a later run builds on). |
| `--default-gas-price`, `--default-max-fee-per-gas`, `--default-max-priority-fee-per-gas`, `--default-max-fee-per-blob-gas` | `shared/live_client_flags` | Pin per-session fees; defaults bump a one-shot live query by `1.5x`. |
| `--max-gas-per-test`, `--max-tx-per-batch`, `--transaction-gas-limit`, ... | `shared/live_client_flags` | Generic live-client knobs reused across commands. |

Expand Down
2 changes: 2 additions & 0 deletions packages/testing/src/execution_testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
)
from .test_types import (
DETERMINISTIC_FACTORY_ADDRESS,
DETERMINISTIC_FACTORY_BYTECODE,
EOA,
Alloc,
AuthorizationTuple,
Expand Down Expand Up @@ -132,6 +133,7 @@

__all__ = (
"DETERMINISTIC_FACTORY_ADDRESS",
"DETERMINISTIC_FACTORY_BYTECODE",
"AccessList",
"Account",
"Address",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ def pytest_addoption(parser: pytest.Parser) -> None:
"This flag checks every account at start_block.)"
),
)
group.addoption(
"--no-reset-between-tests",
action="store_true",
dest="no_reset_between_tests",
default=False,
help=(
"Accumulate state across tests (don't rewind between them). Useful"
"for pre-populating datadir. Fixtures remain valid only from their"
"recorded start_block. Don't mix with single-anchor fills."
),
)


def _resolve_session_fork(
Expand Down Expand Up @@ -507,6 +518,12 @@ def extract_opcode_count(request: pytest.FixtureRequest) -> bool:
return request.config.getoption("extract_opcode_count")


@pytest.fixture(scope="session")
def no_reset_between_tests(request: pytest.FixtureRequest) -> bool:
"""Whether --no-reset-between-tests state accumulation is enabled."""
return request.config.getoption("no_reset_between_tests")


@pytest.fixture(scope="session")
def client_backend(
eth_rpc: ChainBuilderEthRPC,
Expand Down Expand Up @@ -770,6 +787,7 @@ def _reset_chain_between_tests(
client_backend: ClientBackend,
debug_rpc: DebugRPC,
eth_rpc: "ChainBuilderEthRPC",
no_reset_between_tests: bool,
) -> Generator[None, None, None]:
"""
Rewind to start_block after each test so the chain is identical for
Expand All @@ -781,6 +799,8 @@ def _reset_chain_between_tests(
drifted (e.g. a live reorg).
"""
yield
if no_reset_between_tests:
return
if client_backend.start_block is None:
return
start_hex = client_backend.start_block["number"]
Expand Down
46 changes: 46 additions & 0 deletions tests/benchmark/helper/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Fork,
Hash,
Transaction,
TransactionWithCost,
)

from .enums import CacheStrategy
Expand Down Expand Up @@ -138,3 +139,48 @@ def pack_transactions_into_blocks(
blocks.append(Block(txs=current_txs))

return blocks


def pack_transactions_with_cost_into_blocks(
transactions: list[TransactionWithCost],
gas_limit: int,
) -> list[Block]:
"""
Pack transactions into blocks, tracking both gas dimensions.

A transaction is includable only while its gas limit still fits the
room left in the regular and in the state dimension alike, so the
room a block has left is measured against the larger of the two
running totals. Raise when a single transaction cannot fit an empty
block, which no packing can rescue.
"""
if not transactions:
return []

blocks: list[Block] = []
current_txs: list[TransactionWithCost] = []
current_regular = 0
current_state = 0

for tx in transactions:
tx_gas_limit = int(tx.gas_limit)
if tx_gas_limit > gas_limit:
raise ValueError(
f"transaction gas limit {tx_gas_limit} exceeds the "
f"{gas_limit} block gas limit"
)
room = gas_limit - max(current_regular, current_state)
if tx_gas_limit > room and current_txs:
blocks.append(Block(txs=current_txs))
current_txs = []
current_regular = 0
current_state = 0

current_txs.append(tx)
current_regular += tx.regular_cost
current_state += tx.state_cost

if current_txs:
blocks.append(Block(txs=current_txs))

return blocks
222 changes: 222 additions & 0 deletions tests/benchmark/stateful/bloatnet/test_setup_contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
"""Deploy the CREATE2 contracts for benchmarks."""

import os

import pytest
from execution_testing import (
DETERMINISTIC_FACTORY_ADDRESS,
DETERMINISTIC_FACTORY_BYTECODE,
EOA,
Account,
Alloc,
AuthorizationTuple,
BenchmarkTestFiller,
Fork,
Hash,
Op,
TransactionWithCost,
compute_create2_address,
)

from tests.benchmark.helper.account_creator import (
AccountCreator,
AccountMode,
)
from tests.benchmark.helper.account_sender_receiver import (
DELEGATE_BASE_KEY,
)
from tests.benchmark.helper.transactions import (
pack_transactions_with_cost_into_blocks,
)
from tests.prague.eip7702_set_code_tx.spec import Spec as Spec7702

# Number of CREATE2 receiver contracts deployed per mode. Overridable via
# BLOATNET_RECEIVER_CONTRACT_COUNT so a smoke/plumbing run (e.g. benchmarkoor
# pre_runs) can deploy a small set for fast iteration; defaults to the full
# 100k benchmark set.
RECEIVER_CONTRACT_COUNT = int(
os.environ.get("BLOATNET_RECEIVER_CONTRACT_COUNT", "100000")
)

CONTRACT_MODES = [
AccountMode.EXISTING_CONTRACT_MINIMAL,
AccountMode.EXISTING_CONTRACT_SAME_MAX,
AccountMode.EXISTING_CONTRACT_DIFF_MAX,
]

# Factory-frame and initcode execution costs (CALLDATACOPY, the MCOPY
# doubling loop, memory expansion) plus CREATE2's 63/64 retention.
EXECUTION_GAS_BUFFER = 50_000


def deployment_gas(
fork: Fork, initcode: bytes, runtime_size: int
) -> tuple[int, int]:
"""
Return the (regular, state) gas for one CREATE2 deployment, derived
from the intrinsic, CREATE2, and code-deposit costs with a margin
for the factory and initcode execution.

The opcode costs are two-dimensional totals, so the state gas the
account creation and the code deposit charge is split back out; the
margin lands on the regular side, which is what it pays for.
"""
initcode_size = len(initcode)
intrinsic = fork.transaction_intrinsic_cost_calculator()(
calldata=b"\xff" * 32 + initcode
)
create_cost = Op.CREATE2(

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.

Nit: we could take the code of the deterministic deployment factory (EIP-7997) and use that to calculate the actual gas cost

The code is:

https://eips.ethereum.org/EIPS/eip-7997#specification

0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3

(pretty sure we have this as constant in EELS somewhere also)

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.

Added in the new commit.

value=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.

This is not the behavior of the CREATE2 factory, it will forward CALLVALUE

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 only for calculating creation cost (test_deploy_existing_contracts), and since there's no value transfer when deploying max code size contracts, it should reflect the exact deployment behavior.

offset=0,
size=initcode_size,
salt=0,
# Gas accounting
init_code_size=initcode_size,
).gas_cost(fork)
# The factory frame wrapped around that CREATE2: its own bytecode,
# less the CREATE2 already counted above, plus the copy of the
# initcode out of the calldata that the bytecode alone cannot size.
factory_cost = (
DETERMINISTIC_FACTORY_BYTECODE.gas_cost(fork)
- Op.CREATE2(value=0, offset=0, size=0, salt=0).gas_cost(fork)
+ Op.CALLDATACOPY(
dest_offset=0,
offset=32,
size=initcode_size,
# Gas accounting
data_size=initcode_size,
old_memory_size=0,
new_memory_size=initcode_size,
).gas_cost(fork)
)
deposit_cost = Op.RETURN(
0,
runtime_size,
code_deposit_size=runtime_size,
).gas_cost(fork)
base = intrinsic + factory_cost + create_cost + deposit_cost
state = fork.create_state_gas(code_size=runtime_size)
regular = base - state + base // 16 + EXECUTION_GAS_BUFFER
return regular, state


@pytest.mark.valid_from("Amsterdam")
def test_deploy_existing_contracts(
benchmark_test: BenchmarkTestFiller,
pre: Alloc,
fork: Fork,
gas_benchmark_value: int,
tx_gas_limit: int,
) -> None:
"""
Deploy the contracts behind the `AccountMode.EXISTING_CONTRACT_*`
receivers via the deterministic CREATE2 factory.

Delegate deterministic EOAs to EXISTING_CONTRACT_DIFF_MAX receivers.
"""
txs = []
post: dict = {}
for account_mode in CONTRACT_MODES:
creator = AccountCreator(account_mode)
initcode = creator.initcode
regular_gas, state_gas = deployment_gas(
fork, initcode, creator.runtime_size
)
sender = pre.fund_eoa()
for salt in range(RECEIVER_CONTRACT_COUNT):
txs.append(
TransactionWithCost(
to=DETERMINISTIC_FACTORY_ADDRESS,
data=Hash(salt) + initcode,
gas_limit=regular_gas + state_gas,
sender=sender,
regular_cost=regular_gas,
state_cost=state_gas,
)
)
# Nonce 1 at the CREATE2-derived address proves deployment.
for salt in (0, RECEIVER_CONTRACT_COUNT - 1):
contract = compute_create2_address(
address=DETERMINISTIC_FACTORY_ADDRESS,
salt=salt,
initcode=initcode,
)
post[contract] = Account(nonce=1)

# Delegate authority i to the i-th DIFF receiver (EIP-7702).
delegation_sender = pre.fund_eoa()
intrinsic = fork.transaction_intrinsic_cost_calculator()
top_frame = fork.transaction_top_frame_gas_calculator()
# DIFF receivers share one initcode; build it once for CREATE2 derivation.
diff_initcode = AccountCreator(
AccountMode.EXISTING_CONTRACT_DIFF_MAX
).initcode

authorizations = []
for i in range(RECEIVER_CONTRACT_COUNT):
authority = EOA(key=DELEGATE_BASE_KEY + i)
target = compute_create2_address(
address=DETERMINISTIC_FACTORY_ADDRESS,
salt=i,
initcode=diff_initcode,
)
authorizations.append(
AuthorizationTuple(
address=target,
nonce=0,
signer=authority,
# The authorities are deterministic and never funded, so
# applying the delegation is what brings them into being.
creates_account=True,
)
)
if i == 0 or i == RECEIVER_CONTRACT_COUNT - 1:
post[authority] = Account(
nonce=1,
code=Spec7702.delegation_designation(target),
)

def authorization_gas(
authorization_list: list[AuthorizationTuple],
) -> tuple[int, int]:
"""Return the (regular, state) gas an authorization list costs."""
regular = intrinsic(
authorization_list_or_count=len(authorization_list)
) + top_frame(authorizations=authorization_list)
state = fork.transaction_top_frame_state_gas(
authorizations=authorization_list
)
return regular, state

gas_buffer = 100_000
base_regular, base_state = authorization_gas([])
one_regular, one_state = authorization_gas(authorizations[:1])
per_auth_gas = (one_regular + one_state) - (base_regular + base_state)
auths_per_tx = max(
1,
(tx_gas_limit - gas_buffer - base_regular - base_state)
// per_auth_gas,
)

for start in range(0, RECEIVER_CONTRACT_COUNT, auths_per_tx):
authorization_list = authorizations[start : start + auths_per_tx]
regular_gas, state_gas = authorization_gas(authorization_list)
txs.append(
TransactionWithCost(
to=delegation_sender,
gas_limit=regular_gas + state_gas + gas_buffer,
sender=delegation_sender,
authorization_list=authorization_list,
regular_cost=regular_gas + gas_buffer,
state_cost=state_gas,
)
)

benchmark_test(
post=post,
blocks=pack_transactions_with_cost_into_blocks(
txs, gas_benchmark_value
),
skip_gas_used_validation=True,
expected_receipt_status=1,
)
Loading