diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bae344e295f..b39b34ff8c7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,9 @@ Test fixtures for use by clients are available for each release on the [Github r - ✨ Add eof example valid invalid tests from ori, fetch EOF Container implementation ([#535](https://github.com/ethereum/execution-spec-tests/pull/535)). - ✨ Add tests for [EIP-2537: Precompile for BLS12-381 curve operations](https://eips.ethereum.org/EIPS/eip-2537) ([#499](https://github.com/ethereum/execution-spec-tests/pull/499)). - ✨ [EIP-663](https://eips.ethereum.org/EIPS/eip-663): Add `test_dupn.py` and `test_swapn.py` ([#502](https://github.com/ethereum/execution-spec-tests/pull/502)). +- ✨ Add tests for [EIP-6110: Supply validator deposits on chain](https://eips.ethereum.org/EIPS/eip-6110) ([#530](https://github.com/ethereum/execution-spec-tests/pull/530)). +- ✨ Add tests for [EIP-7002: Execution layer triggerable withdrawals](https://eips.ethereum.org/EIPS/eip-7002) ([#530](https://github.com/ethereum/execution-spec-tests/pull/530)). +- ✨ Add tests for [EIP-7685: General purpose execution layer requests](https://eips.ethereum.org/EIPS/eip-7685) ([#530](https://github.com/ethereum/execution-spec-tests/pull/530)). ### 🛠️ Framework diff --git a/setup.cfg b/setup.cfg index a98dc5c0568..659c91453e2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,6 +49,7 @@ ethereum_test_tools = py.typed ethereum_test_forks = py.typed + forks/*.bin evm_transition_tool = py.typed pytest_plugins = diff --git a/src/ethereum_test_forks/base_fork.py b/src/ethereum_test_forks/base_fork.py index c8823fed33d..4126498e280 100644 --- a/src/ethereum_test_forks/base_fork.py +++ b/src/ethereum_test_forks/base_fork.py @@ -149,6 +149,14 @@ def header_beacon_root_required(cls, block_number: int, timestamp: int) -> bool: """ pass + @classmethod + @abstractmethod + def header_requests_required(cls, block_number: int, timestamp: int) -> bool: + """ + Returns true if the header must contain beacon chain requests + """ + pass + @classmethod @abstractmethod def blob_gas_per_blob(cls, block_number: int, timestamp: int) -> int: diff --git a/src/ethereum_test_forks/forks/deposit_contract.bin b/src/ethereum_test_forks/forks/deposit_contract.bin new file mode 100644 index 00000000000..4e448734739 Binary files /dev/null and b/src/ethereum_test_forks/forks/deposit_contract.bin differ diff --git a/src/ethereum_test_forks/forks/forks.py b/src/ethereum_test_forks/forks/forks.py index bd780f5a399..5abdbc0fff4 100644 --- a/src/ethereum_test_forks/forks/forks.py +++ b/src/ethereum_test_forks/forks/forks.py @@ -2,12 +2,18 @@ All Ethereum fork class definitions. """ +from hashlib import sha256 +from os.path import realpath +from pathlib import Path from typing import List, Mapping, Optional from semver import Version from ..base_fork import BaseFork +CURRENT_FILE = Path(realpath(__file__)) +CURRENT_FOLDER = CURRENT_FILE.parent + # All forks must be listed here !!! in the order they were introduced !!! class Frontier(BaseFork, solc_name="homestead"): @@ -89,6 +95,13 @@ def blob_gas_per_blob(cls, block_number: int, timestamp: int) -> int: """ return 0 + @classmethod + def header_requests_required(cls, block_number: int, timestamp: int) -> bool: + """ + At genesis, header must not contain beacon chain requests. + """ + return False + @classmethod def engine_new_payload_version( cls, block_number: int = 0, timestamp: int = 0 @@ -492,6 +505,53 @@ def precompiles(cls, block_number: int = 0, timestamp: int = 0) -> List[int]: """ return list(range(0xB, 0x13 + 1)) + super(Prague, cls).precompiles(block_number, timestamp) + @classmethod + def pre_allocation_blockchain(cls) -> Mapping: + """ + Prague requires pre-allocation of the beacon chain deposit contract for EIP-6110, and + the exits contract for EIP-7002. + """ + new_allocation = {} + + # Add the beacon chain deposit contract + DEPOSIT_CONTRACT_TREE_DEPTH = 32 + storage = {} + next_hash = sha256(b"\x00" * 64).digest() + for i in range(DEPOSIT_CONTRACT_TREE_DEPTH + 2, DEPOSIT_CONTRACT_TREE_DEPTH * 2 + 1): + storage[i] = next_hash + next_hash = sha256(next_hash + next_hash).digest() + + with open(CURRENT_FOLDER / "deposit_contract.bin", mode="rb") as f: + new_allocation.update( + { + 0x00000000219AB540356CBB839CBE05303D7705FA: { + "nonce": 1, + "code": f.read(), + "storage": storage, + } + } + ) + + # Add the withdrawal request contract + with open(CURRENT_FOLDER / "withdrawal_request.bin", mode="rb") as f: + new_allocation.update( + { + 0x00A3CA265EBCB825B45F985A16CEFB49958CE017: { + "nonce": 1, + "code": f.read(), + }, + } + ) + return new_allocation | super(Prague, cls).pre_allocation_blockchain() + + @classmethod + def header_requests_required(cls, block_number: int, timestamp: int) -> bool: + """ + Prague requires that the execution layer block contains the beacon + chain requests. + """ + return True + @classmethod def engine_new_payload_version( cls, block_number: int = 0, timestamp: int = 0 diff --git a/src/ethereum_test_forks/forks/withdrawal_request.bin b/src/ethereum_test_forks/forks/withdrawal_request.bin new file mode 100644 index 00000000000..426247e9512 Binary files /dev/null and b/src/ethereum_test_forks/forks/withdrawal_request.bin differ diff --git a/src/ethereum_test_tools/__init__.py b/src/ethereum_test_tools/__init__.py index 54d78faca0b..3db7d748dce 100644 --- a/src/ethereum_test_tools/__init__.py +++ b/src/ethereum_test_tools/__init__.py @@ -19,6 +19,7 @@ Account, Address, Alloc, + DepositRequest, EngineAPIError, Environment, Hash, @@ -31,6 +32,7 @@ TestPrivateKey2, Transaction, Withdrawal, + WithdrawalRequest, add_kzg_version, ceiling_division, compute_create2_address, @@ -77,6 +79,7 @@ "Code", "CodeGasMeasure", "Conditional", + "DepositRequest", "EngineAPIError", "Environment", "EOFException", @@ -109,6 +112,7 @@ "Transaction", "TransactionException", "Withdrawal", + "WithdrawalRequest", "Yul", "YulCompiler", "add_kzg_version", diff --git a/src/ethereum_test_tools/common/__init__.py b/src/ethereum_test_tools/common/__init__.py index 939a409aa46..fd84bb23649 100644 --- a/src/ethereum_test_tools/common/__init__.py +++ b/src/ethereum_test_tools/common/__init__.py @@ -39,11 +39,14 @@ AccessList, Account, Alloc, + DepositRequest, Environment, Removable, + Requests, Storage, Transaction, Withdrawal, + WithdrawalRequest, ) __all__ = ( @@ -55,6 +58,7 @@ "Alloc", "Bloom", "Bytes", + "DepositRequest", "EngineAPIError", "EmptyOmmersRoot", "EmptyTrieRoot", @@ -64,6 +68,7 @@ "HexNumber", "Number", "Removable", + "Requests", "Storage", "TestAddress", "TestAddress2", @@ -72,6 +77,7 @@ "TestPrivateKey2", "Transaction", "Withdrawal", + "WithdrawalRequest", "ZeroPaddedHexNumber", "add_kzg_version", "ceiling_division", diff --git a/src/ethereum_test_tools/common/base_types.py b/src/ethereum_test_tools/common/base_types.py index 23e90590f0d..c3f0d255d3e 100644 --- a/src/ethereum_test_tools/common/base_types.py +++ b/src/ethereum_test_tools/common/base_types.py @@ -302,3 +302,19 @@ class HeaderNonce(FixedSizeBytes[8]): # type: ignore """ pass + + +class BLSPublicKey(FixedSizeBytes[48]): # type: ignore + """ + Class that helps represent BLS public keys in tests. + """ + + pass + + +class BLSSignature(FixedSizeBytes[96]): # type: ignore + """ + Class that helps represent BLS signatures in tests. + """ + + pass diff --git a/src/ethereum_test_tools/common/types.py b/src/ethereum_test_tools/common/types.py index 1296f5b83a3..cd9db3168fd 100644 --- a/src/ethereum_test_tools/common/types.py +++ b/src/ethereum_test_tools/common/types.py @@ -45,6 +45,8 @@ from .base_types import ( Address, Bloom, + BLSPublicKey, + BLSSignature, Bytes, Hash, HashInt, @@ -1240,6 +1242,139 @@ def list_blob_versioned_hashes(input_txs: List["Transaction"]) -> List[Hash]: ] +class RequestBase: + """ + Base class for requests. + """ + + @classmethod + def type_byte(cls) -> bytes: + """ + Returns the request type. + """ + raise NotImplementedError("request_type must be implemented in child classes") + + def to_serializable_list(self) -> List[Any]: + """ + Returns the request's attributes as a list of serializable elements. + """ + raise NotImplementedError("to_serializable_list must be implemented in child classes") + + +class DepositRequestGeneric(RequestBase, CamelModel, Generic[NumberBoundTypeVar]): + """ + Generic deposit type used as a parent for DepositRequest and FixtureDepositRequest. + """ + + pubkey: BLSPublicKey + withdrawal_credentials: Hash + amount: NumberBoundTypeVar + signature: BLSSignature + index: NumberBoundTypeVar + + @classmethod + def type_byte(cls) -> bytes: + """ + Returns the deposit request type. + """ + return b"\0" + + def to_serializable_list(self) -> List[Any]: + """ + Returns the deposit's attributes as a list of serializable elements. + """ + return [ + self.pubkey, + self.withdrawal_credentials, + Uint(self.amount), + self.signature, + Uint(self.index), + ] + + +class DepositRequest(DepositRequestGeneric[HexNumber]): + """ + Deposit Request type + """ + + pass + + +class WithdrawalRequestGeneric(RequestBase, CamelModel, Generic[NumberBoundTypeVar]): + """ + Generic withdrawal request type used as a parent for WithdrawalRequest and + FixtureWithdrawalRequest. + """ + + source_address: Address = Address(0) + validator_public_key: BLSPublicKey + amount: NumberBoundTypeVar + + @classmethod + def type_byte(cls) -> bytes: + """ + Returns the withdrawal request type. + """ + return b"\1" + + def to_serializable_list(self) -> List[Any]: + """ + Returns the deposit's attributes as a list of serializable elements. + """ + return [ + self.source_address, + self.validator_public_key, + Uint(self.amount), + ] + + +class WithdrawalRequest(WithdrawalRequestGeneric[HexNumber]): + """ + Withdrawal Request type + """ + + pass + + +class Requests(RootModel[List[DepositRequest | WithdrawalRequest]]): + """ + Requests for the transition tool. + """ + + root: List[DepositRequest | WithdrawalRequest] = Field(default_factory=list) + + def to_serializable_list(self) -> List[Any]: + """ + Returns the requests as a list of serializable elements. + """ + return [r.type_byte() + eth_rlp.encode(r.to_serializable_list()) for r in self.root] + + @cached_property + def trie_root(self) -> Hash: + """ + Returns the root hash of the requests. + """ + t = HexaryTrie(db={}) + for i, r in enumerate(self.root): + t.set( + eth_rlp.encode(Uint(i)), + r.type_byte() + eth_rlp.encode(r.to_serializable_list()), + ) + return Hash(t.root_hash) + + def deposit_requests(self) -> List[DepositRequest]: + """ + Returns the list of deposit requests. + """ + return [d for d in self.root if isinstance(d, DepositRequest)] + + def withdrawal_requests(self) -> List[WithdrawalRequest]: + """ + Returns the list of withdrawal requests. + """ + return [w for w in self.root if isinstance(w, WithdrawalRequest)] + + # TODO: Move to other file # Transition tool models @@ -1310,6 +1445,9 @@ class Result(CamelModel): withdrawals_root: Hash | None = None excess_blob_gas: HexNumber | None = Field(None, alias="currentExcessBlobGas") blob_gas_used: HexNumber | None = None + requests_root: Hash | None = None + deposit_requests: List[DepositRequest] | None = None + withdrawal_requests: List[WithdrawalRequest] | None = None class TransitionToolOutput(CamelModel): diff --git a/src/ethereum_test_tools/exceptions/exceptions.py b/src/ethereum_test_tools/exceptions/exceptions.py index 01be8b5397d..e6e9c46c900 100644 --- a/src/ethereum_test_tools/exceptions/exceptions.py +++ b/src/ethereum_test_tools/exceptions/exceptions.py @@ -180,6 +180,10 @@ class BlockException(ExceptionBase): """ Block's rlp encoding is valid but ethereum structures in it are invalid """ + INVALID_REQUESTS = auto() + """ + Block's requests are invalid + """ @unique diff --git a/src/ethereum_test_tools/spec/blockchain/blockchain_test.py b/src/ethereum_test_tools/spec/blockchain/blockchain_test.py index d9e61ce1f7a..2cd1366d029 100644 --- a/src/ethereum_test_tools/spec/blockchain/blockchain_test.py +++ b/src/ethereum_test_tools/spec/blockchain/blockchain_test.py @@ -10,10 +10,10 @@ from ethereum_test_forks import Fork from evm_transition_tool import FixtureFormats, TransitionTool -from ...common import Alloc, EmptyTrieRoot, Environment, Hash, Transaction, Withdrawal +from ...common import Alloc, EmptyTrieRoot, Environment, Hash, Requests, Transaction, Withdrawal from ...common.constants import EmptyOmmersRoot from ...common.json import to_json -from ...common.types import TransitionToolOutput +from ...common.types import DepositRequest, TransitionToolOutput, WithdrawalRequest from ..base.base_test import BaseFixture, BaseTest, verify_result, verify_transactions from ..debugging import print_traces from .types import ( @@ -22,10 +22,12 @@ Fixture, FixtureBlock, FixtureBlockBase, + FixtureDepositRequest, FixtureEngineNewPayload, FixtureHeader, FixtureTransaction, FixtureWithdrawal, + FixtureWithdrawalRequest, HiveFixture, InvalidFixtureBlock, ) @@ -138,6 +140,9 @@ def make_genesis( if env.withdrawals is not None else None, parent_beacon_block_root=env.parent_beacon_block_root, + requests_root=Requests(root=[]).trie_root + if fork.header_requests_required(0, 0) + else None, ) return ( @@ -145,7 +150,11 @@ def make_genesis( FixtureBlockBase( header=genesis, withdrawals=None if env.withdrawals is None else [], - ).with_rlp(txs=[]), + deposit_requests=[] if fork.header_requests_required(0, 0) else None, + withdrawal_requests=[] if fork.header_requests_required(0, 0) else None, + ).with_rlp( + txs=[], requests=Requests() if fork.header_requests_required(0, 0) else None + ), ) def generate_block_data( @@ -156,7 +165,7 @@ def generate_block_data( previous_env: Environment, previous_alloc: Alloc, eips: Optional[List[int]] = None, - ) -> Tuple[FixtureHeader, List[Transaction], Alloc, Environment]: + ) -> Tuple[FixtureHeader, List[Transaction], Requests | None, Alloc, Environment]: """ Generate common block data for both make_fixture and make_hive_fixture. """ @@ -248,7 +257,33 @@ def generate_block_data( # transition tool processing. header = header.join(block.rlp_modifier) - return header, txs, transition_tool_output.alloc, env + requests = None + if fork.header_requests_required(header.number, header.timestamp): + requests_list: List[DepositRequest | WithdrawalRequest] = [] + if transition_tool_output.result.deposit_requests is not None: + requests_list += transition_tool_output.result.deposit_requests + if transition_tool_output.result.withdrawal_requests is not None: + requests_list += transition_tool_output.result.withdrawal_requests + requests = Requests(root=requests_list) + + if requests is not None and requests.trie_root != header.requests_root: + raise Exception( + f"Requests root in header does not match the requests root in the transition tool " + "output: " + f"{header.requests_root} != {requests.trie_root}" + ) + + if block.requests is not None: + requests = Requests(root=block.requests) + header.requests_root = requests.trie_root + + return ( + header, + txs, + requests, + transition_tool_output.alloc, + env, + ) def network_info(self, fork: Fork, eips: Optional[List[int]] = None): """ @@ -292,7 +327,7 @@ def make_fixture( # This is the most common case, the RLP needs to be constructed # based on the transactions to be included in the block. # Set the environment according to the block to execute. - header, txs, new_alloc, new_env = self.generate_block_data( + header, txs, requests, new_alloc, new_env = self.generate_block_data( t8n=t8n, fork=fork, block=block, @@ -307,7 +342,19 @@ def make_fixture( withdrawals=[FixtureWithdrawal.from_withdrawal(w) for w in new_env.withdrawals] if new_env.withdrawals is not None else None, - ).with_rlp(txs=txs) + deposit_requests=[ + FixtureDepositRequest.from_deposit_request(d) + for d in requests.deposit_requests() + ] + if requests is not None + else None, + withdrawal_requests=[ + FixtureWithdrawalRequest.from_withdrawal_request(w) + for w in requests.withdrawal_requests() + ] + if requests is not None + else None, + ).with_rlp(txs=txs, requests=requests) if block.exception is None: fixture_blocks.append(fixture_block) # Update env, alloc and last block hash for the next block. @@ -366,7 +413,7 @@ def make_hive_fixture( head_hash = genesis.header.block_hash for block in self.blocks: - header, txs, new_alloc, new_env = self.generate_block_data( + header, txs, requests, new_alloc, new_env = self.generate_block_data( t8n=t8n, fork=fork, block=block, previous_env=env, previous_alloc=alloc, eips=eips ) if block.rlp is None: @@ -376,6 +423,7 @@ def make_hive_fixture( header=header, transactions=txs, withdrawals=new_env.withdrawals, + requests=requests, validation_error=block.exception, error_code=block.engine_api_error_code, ) @@ -402,7 +450,7 @@ def make_hive_fixture( # Most clients require the header to start the sync process, so we create an empty # block on top of the last block of the test to send it as new payload and trigger the # sync process. - sync_header, _, _, _ = self.generate_block_data( + sync_header, _, requests, _, _ = self.generate_block_data( t8n=t8n, fork=fork, block=Block(), @@ -415,6 +463,7 @@ def make_hive_fixture( header=sync_header, transactions=[], withdrawals=[], + requests=requests, validation_error=None, error_code=None, ) diff --git a/src/ethereum_test_tools/spec/blockchain/types.py b/src/ethereum_test_tools/spec/blockchain/types.py index a67f9588afd..9b00d6f5c5a 100644 --- a/src/ethereum_test_tools/spec/blockchain/types.py +++ b/src/ethereum_test_tools/spec/blockchain/types.py @@ -8,7 +8,14 @@ from ethereum import rlp as eth_rlp from ethereum.base_types import Uint from ethereum.crypto.hash import keccak256 -from pydantic import AliasChoices, ConfigDict, Field, PlainSerializer, computed_field +from pydantic import ( + AliasChoices, + ConfigDict, + Field, + PlainSerializer, + computed_field, + field_validator, +) from ethereum_test_forks import Fork from evm_transition_tool import FixtureFormats @@ -27,13 +34,18 @@ from ...common.types import ( Alloc, CamelModel, + DepositRequest, + DepositRequestGeneric, Environment, Removable, + Requests, Transaction, TransactionFixtureConverter, TransactionGeneric, Withdrawal, WithdrawalGeneric, + WithdrawalRequest, + WithdrawalRequestGeneric, ) from ...exceptions import BlockException, ExceptionInstanceOrList, TransactionException from ..base.base_test import BaseFixture @@ -64,6 +76,7 @@ class Header(CamelModel): blob_gas_used: Removable | HexNumber | None = None excess_blob_gas: Removable | HexNumber | None = None parent_beacon_block_root: Removable | Hash | None = None + requests_root: Removable | Hash | None = None REMOVE_FIELD: ClassVar[Removable] = Removable() """ @@ -97,6 +110,26 @@ class Header(CamelModel): }, ) + @field_validator("withdrawals_root", mode="before") + @classmethod + def validate_withdrawals_root(cls, value): + """ + Helper validator to convert a list of withdrawals into the withdrawals root hash. + """ + if isinstance(value, list): + return Withdrawal.list_root(value) + return value + + @field_validator("requests_root", mode="before") + @classmethod + def validate_requests_root(cls, value): + """ + Helper validator to convert a list of requests into the requests root hash. + """ + if isinstance(value, list): + return Requests(root=value).trie_root + return value + class HeaderForkRequirement(str): """ @@ -172,6 +205,7 @@ class FixtureHeader(CamelModel): parent_beacon_block_root: Annotated[Hash, HeaderForkRequirement("beacon_root")] | None = Field( None ) + requests_root: Annotated[Hash, HeaderForkRequirement("requests")] | None = Field(None) fork: Fork | None = Field(None, exclude=True) @@ -311,6 +345,10 @@ class Block(Header): """ List of withdrawals to perform for this block. """ + requests: List[DepositRequest | WithdrawalRequest] | None = None + """ + Custom list of requests to embed in this block. + """ def set_environment(self, env: Environment) -> Environment: """ @@ -388,13 +426,16 @@ class FixtureExecutionPayload(CamelModel): transactions: List[Bytes] withdrawals: List[Withdrawal] | None = None + deposit_requests: List[DepositRequest] | None = None + withdrawal_requests: List[WithdrawalRequest] | None = None @classmethod def from_fixture_header( cls, header: FixtureHeader, transactions: List[Transaction], - withdrawals: List[Withdrawal] | None = None, + withdrawals: List[Withdrawal] | None, + requests: Requests | None, ) -> "FixtureExecutionPayload": """ Returns a FixtureExecutionPayload from a FixtureHeader, a list @@ -404,6 +445,8 @@ def from_fixture_header( **header.model_dump(exclude={"rlp"}, exclude_none=True), transactions=[tx.rlp for tx in transactions], withdrawals=withdrawals, + deposit_requests=requests.deposit_requests() if requests is not None else None, + withdrawal_requests=requests.withdrawal_requests() if requests is not None else None, ) @@ -436,6 +479,7 @@ def from_fixture_header( header: FixtureHeader, transactions: List[Transaction], withdrawals: List[Withdrawal] | None, + requests: Requests | None, **kwargs, ) -> "FixtureEngineNewPayload": """ @@ -450,6 +494,7 @@ def from_fixture_header( header=header, transactions=transactions, withdrawals=withdrawals, + requests=requests, ), version=new_payload_version, blob_versioned_hashes=( @@ -491,6 +536,34 @@ def from_withdrawal(cls, w: WithdrawalGeneric) -> "FixtureWithdrawal": return cls(**w.model_dump()) +class FixtureDepositRequest(DepositRequestGeneric[ZeroPaddedHexNumber]): + """ + Structure to represent a single deposit request to be processed by the beacon + chain. + """ + + @classmethod + def from_deposit_request(cls, d: DepositRequestGeneric) -> "FixtureDepositRequest": + """ + Returns a FixtureDepositRequest from a DepositRequest. + """ + return cls(**d.model_dump()) + + +class FixtureWithdrawalRequest(WithdrawalRequestGeneric[ZeroPaddedHexNumber]): + """ + Structure to represent a single withdrawal request to be processed by the beacon + chain. + """ + + @classmethod + def from_withdrawal_request(cls, d: WithdrawalRequestGeneric) -> "FixtureWithdrawalRequest": + """ + Returns a FixtureWithdrawalRequest from a WithdrawalRequest. + """ + return cls(**d.model_dump()) + + class FixtureBlockBase(CamelModel): """Representation of an Ethereum block within a test Fixture without RLP bytes.""" @@ -498,6 +571,8 @@ class FixtureBlockBase(CamelModel): txs: List[FixtureTransaction] = Field(default_factory=list, alias="transactions") ommers: List[FixtureHeader] = Field(default_factory=list, alias="uncleHeaders") withdrawals: List[FixtureWithdrawal] | None = None + deposit_requests: List[FixtureDepositRequest] | None = None + withdrawal_requests: List[FixtureWithdrawalRequest] | None = None @computed_field(alias="blocknumber") # type: ignore[misc] @cached_property @@ -507,7 +582,7 @@ def block_number(self) -> Number: """ return Number(self.header.number) - def with_rlp(self, txs: List[Transaction]) -> "FixtureBlock": + def with_rlp(self, txs: List[Transaction], requests: Requests | None) -> "FixtureBlock": """ Returns a FixtureBlock with the RLP bytes set. """ @@ -520,6 +595,9 @@ def with_rlp(self, txs: List[Transaction]) -> "FixtureBlock": if self.withdrawals is not None: block.append([w.to_serializable_list() for w in self.withdrawals]) + if requests is not None: + block.append(requests.to_serializable_list()) + return FixtureBlock( **self.model_dump(), rlp=eth_rlp.encode(block), diff --git a/src/ethereum_test_tools/tests/test_types.py b/src/ethereum_test_tools/tests/test_types.py index 76ec5835a4f..fe46c1fd7df 100644 --- a/src/ethereum_test_tools/tests/test_types.py +++ b/src/ethereum_test_tools/tests/test_types.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List import pytest +from pydantic import TypeAdapter from ..common import ( AccessList, @@ -18,7 +19,7 @@ from ..common.base_types import Address, Bloom, Bytes, Hash, HeaderNonce, ZeroPaddedHexNumber from ..common.constants import TestPrivateKey from ..common.json import to_json -from ..common.types import Alloc +from ..common.types import Alloc, DepositRequest, Requests from ..exceptions import BlockException, TransactionException from ..spec.blockchain.types import ( FixtureBlockBase, @@ -1130,6 +1131,7 @@ def test_account_merge( ).with_signature_and_sender(), ], withdrawals=[Withdrawal(index=0, validator_index=1, address=0x1234, amount=2)], + requests=None, ), { "parentHash": Hash(0).hex(), @@ -1222,6 +1224,7 @@ def test_account_merge( amount=2, ) ], + requests=None, ), validation_error=TransactionException.INTRINSIC_GAS_TOO_LOW, version=1, @@ -1322,6 +1325,7 @@ def test_account_merge( ).with_signature_and_sender(), ], withdrawals=[Withdrawal(index=0, validator_index=1, address=0x1234, amount=2)], + requests=None, ), version=1, validation_error=[ @@ -1658,3 +1662,42 @@ def test_withdrawals_root(withdrawals: List[Withdrawal], expected_root: bytes): Test that withdrawals_root returns the expected hash. """ assert Withdrawal.list_root(withdrawals) == expected_root + + +@pytest.mark.parametrize( + ["json_str", "type_adapter", "expected"], + [ + pytest.param( + """ + [ + { + "type": "0x0", + "pubkey": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001", + "withdrawalCredentials": "0x0000000000000000000000000000000000000000000000000000000000000002", + "amount": "0x1234", + "signature": "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003", + "index": "0x5678" + } + ] + """, # noqa: E501 + TypeAdapter(Requests), + Requests( + root=[ + DepositRequest( + pubkey=1, + withdrawal_credentials=2, + amount=0x1234, + signature=3, + index=0x5678, + ), + ] + ), + id="requests_1", + ), + ], +) +def test_parsing(json_str: str, type_adapter: TypeAdapter, expected: Any): + """ + Test that parsing the given JSON string returns the expected object. + """ + assert type_adapter.validate_json(json_str) == expected diff --git a/tests/prague/eip6110_deposits/__init__.py b/tests/prague/eip6110_deposits/__init__.py new file mode 100644 index 00000000000..d1038f50fc8 --- /dev/null +++ b/tests/prague/eip6110_deposits/__init__.py @@ -0,0 +1,3 @@ +""" +Cross-client EIP-6110 Tests +""" diff --git a/tests/prague/eip6110_deposits/conftest.py b/tests/prague/eip6110_deposits/conftest.py new file mode 100644 index 00000000000..0aa8f6c4c8a --- /dev/null +++ b/tests/prague/eip6110_deposits/conftest.py @@ -0,0 +1,85 @@ +""" +Fixtures for the EIP-6110 deposit tests. +""" +from typing import Dict, List + +import pytest + +from ethereum_test_tools import Account, Address, Block, BlockException, Header, Transaction + +from .helpers import DepositInteractionBase, DepositRequest + + +@pytest.fixture +def pre(requests: List[DepositInteractionBase]) -> Dict[Address, Account]: + """ + Initial state of the accounts. Every deposit transaction defines their own pre-state + requirements, and this fixture aggregates them all. + """ + pre: Dict[Address, Account] = {} + for d in requests: + d.update_pre(pre) + return pre + + +@pytest.fixture +def txs( + requests: List[DepositInteractionBase], +) -> List[Transaction]: + """List of transactions to include in the block.""" + address_nonce: Dict[Address, int] = {} + txs = [] + for r in requests: + nonce = 0 + if r.sender_account.address in address_nonce: + nonce = address_nonce[r.sender_account.address] + txs.append(r.transaction(nonce)) + address_nonce[r.sender_account.address] = nonce + 1 + return txs + + +@pytest.fixture +def block_body_override_requests() -> List[DepositRequest] | None: + """List of requests that overwrite the requests in the header. None by default.""" + return None + + +@pytest.fixture +def exception() -> BlockException | None: + """Block exception expected by the tests. None by default.""" + return None + + +@pytest.fixture +def included_requests( + requests: List[DepositInteractionBase], +) -> List[DepositRequest]: + """ + Return the list of deposit requests that should be included in each block. + """ + valid_requests: List[DepositRequest] = [] + + for d in requests: + valid_requests += d.valid_requests(10**18) + + return valid_requests + + +@pytest.fixture +def blocks( + included_requests: List[DepositRequest], + block_body_override_requests: List[DepositRequest] | None, + txs: List[Transaction], + exception: BlockException | None, +) -> List[Block]: + """List of blocks that comprise the test.""" + return [ + Block( + txs=txs, + header_verify=Header( + requests_root=included_requests, + ), + requests=block_body_override_requests, + exception=exception, + ) + ] diff --git a/tests/prague/eip6110_deposits/helpers.py b/tests/prague/eip6110_deposits/helpers.py new file mode 100644 index 00000000000..cada0c6fb5b --- /dev/null +++ b/tests/prague/eip6110_deposits/helpers.py @@ -0,0 +1,305 @@ +""" +Helpers for the EIP-6110 deposit tests. +""" +from dataclasses import dataclass, field +from functools import cached_property +from hashlib import sha256 as sha256_hashlib +from typing import Callable, ClassVar, Dict, List + +from ethereum_test_tools import Account, Address +from ethereum_test_tools import DepositRequest as DepositRequestBase +from ethereum_test_tools import Hash +from ethereum_test_tools import Opcodes as Op +from ethereum_test_tools import ( + TestAddress, + TestAddress2, + TestPrivateKey, + TestPrivateKey2, + Transaction, +) + +from .spec import Spec + + +def sha256(*args: bytes) -> bytes: + """ + Returns the sha256 hash of the input. + """ + return sha256_hashlib(b"".join(args)).digest() + + +@dataclass +class SenderAccount: + """Test sender account descriptor.""" + + address: Address + key: str + + +TestAccount1 = SenderAccount(TestAddress, TestPrivateKey) +TestAccount2 = SenderAccount(TestAddress2, TestPrivateKey2) + + +class DepositRequest(DepositRequestBase): + """Deposit request descriptor.""" + + valid: bool = True + """ + Whether the deposit request is valid or not. + """ + gas_limit: int = 1_000_000 + """ + Gas limit for the call. + """ + calldata_modifier: Callable[[bytes], bytes] = lambda x: x + """ + Calldata modifier function. + """ + + interaction_contract_address: ClassVar[Address] = Address(Spec.DEPOSIT_CONTRACT_ADDRESS) + + @cached_property + def value(self) -> int: + """ + Returns the value of the deposit transaction. + """ + return self.amount * 10**9 + + @cached_property + def deposit_data_root(self) -> Hash: + """ + Returns the deposit data root of the deposit. + """ + pubkey_root = sha256(self.pubkey, b"\x00" * 16) + signature_root = sha256( + sha256(self.signature[:64]), sha256(self.signature[64:], b"\x00" * 32) + ) + pubkey_withdrawal_root = sha256(pubkey_root, self.withdrawal_credentials) + amount_bytes = (self.amount).to_bytes(32, byteorder="little") + amount_signature_root = sha256(amount_bytes, signature_root) + return Hash(sha256(pubkey_withdrawal_root, amount_signature_root)) + + @cached_property + def calldata(self) -> bytes: + """ + Returns the calldata needed to call the beacon chain deposit contract and make the deposit. + + deposit( + bytes calldata pubkey, + bytes calldata withdrawal_credentials, + bytes calldata signature, + bytes32 deposit_data_root + ) + """ + offset_length = 32 + pubkey_offset = offset_length * 3 + len(self.deposit_data_root) + withdrawal_offset = pubkey_offset + offset_length + len(self.pubkey) + signature_offset = withdrawal_offset + offset_length + len(self.withdrawal_credentials) + return self.calldata_modifier( + b"\x22\x89\x51\x18" + + pubkey_offset.to_bytes(offset_length, byteorder="big") + + withdrawal_offset.to_bytes(offset_length, byteorder="big") + + signature_offset.to_bytes(offset_length, byteorder="big") + + self.deposit_data_root + + len(self.pubkey).to_bytes(offset_length, byteorder="big") + + self.pubkey + + len(self.withdrawal_credentials).to_bytes(offset_length, byteorder="big") + + self.withdrawal_credentials + + len(self.signature).to_bytes(offset_length, byteorder="big") + + self.signature + ) + + +@dataclass(kw_only=True) +class DepositInteractionBase: + """ + Base class for all types of deposit transactions we want to test. + """ + + sender_balance: int = 32_000_000_000_000_000_000 * 100 + """ + Balance of the account that sends the transaction. + """ + sender_account: SenderAccount = field( + default_factory=lambda: SenderAccount(TestAddress, TestPrivateKey) + ) + """ + Account that sends the transaction. + """ + + def transaction(self, nonce: int) -> Transaction: + """Return a transaction for the deposit request.""" + raise NotImplementedError + + def update_pre(self, base_pre: Dict[Address, Account]): + """Return the pre-state of the account.""" + raise NotImplementedError + + def valid_requests(self, current_minimum_fee: int) -> List[DepositRequest]: + """Return the list of deposit requests that should be included in the block.""" + raise NotImplementedError + + +@dataclass(kw_only=True) +class DepositTransaction(DepositInteractionBase): + """Class used to describe a deposit originated from an externally owned account.""" + + request: DepositRequest + """ + Deposit request to be included in the block. + """ + + def transaction(self, nonce: int) -> Transaction: + """Return a transaction for the deposit request.""" + return Transaction( + nonce=nonce, + gas_limit=self.request.gas_limit, + gas_price=0x07, + to=self.request.interaction_contract_address, + value=self.request.value, + data=self.request.calldata, + secret_key=self.sender_account.key, + ) + + def update_pre(self, base_pre: Dict[Address, Account]): + """Return the pre-state of the account.""" + base_pre.update( + { + self.sender_account.address: Account(balance=self.sender_balance), + } + ) + + def valid_requests(self, current_minimum_fee: int) -> List[DepositRequest]: + """Return the list of deposit requests that should be included in the block.""" + return ( + [self.request] + if self.request.valid and self.request.value >= current_minimum_fee + else [] + ) + + +@dataclass(kw_only=True) +class DepositContract(DepositInteractionBase): + """Class used to describe a deposit originated from a contract.""" + + request: List[DepositRequest] | DepositRequest + """ + Deposit request or list of deposit requests to send from the contract. + """ + + tx_gas_limit: int = 1_000_000 + """ + Gas limit for the transaction. + """ + + contract_balance: int = 32_000_000_000_000_000_000 * 100 + """ + Balance of the contract that sends the deposit requests. + """ + contract_address: int = 0x200 + """ + Address of the contract that sends the deposit requests. + """ + + call_type: Op = field(default_factory=lambda: Op.CALL) + """ + Type of call to be made to the deposit contract. + """ + call_depth: int = 2 + """ + Frame depth of the beacon chain deposit contract when it executes the deposit requests. + """ + extra_code: bytes = b"" + """ + Extra code to be included in the contract that sends the deposit requests. + """ + + @property + def requests(self) -> List[DepositRequest]: + """Return the list of deposit requests.""" + if not isinstance(self.request, List): + return [self.request] + return self.request + + @property + def contract_code(self) -> bytes: + """Contract code used by the relay contract.""" + code = b"" + current_offset = 0 + for r in self.requests: + value_arg = [r.value] if self.call_type in (Op.CALL, Op.CALLCODE) else [] + code += Op.CALLDATACOPY(0, current_offset, len(r.calldata)) + Op.POP( + self.call_type( + Op.GAS if r.gas_limit == -1 else r.gas_limit, + r.interaction_contract_address, + *value_arg, + 0, + len(r.calldata), + 0, + 0, + ) + ) + current_offset += len(r.calldata) + return code + self.extra_code + + def transaction(self, nonce: int) -> Transaction: + """Return a transaction for the deposit request.""" + return Transaction( + nonce=nonce, + gas_limit=self.tx_gas_limit, + gas_price=0x07, + to=self.entry_address(), + value=0, + data=b"".join(r.calldata for r in self.requests), + secret_key=self.sender_account.key, + ) + + def entry_address(self) -> Address: + """Return the address of the contract entry point.""" + if self.call_depth == 2: + return Address(self.contract_address) + elif self.call_depth > 2: + return Address(self.contract_address + self.call_depth - 2) + raise ValueError("Invalid call depth") + + def extra_contracts(self) -> Dict[Address, Account]: + """Extra contracts used to simulate call depth.""" + if self.call_depth <= 2: + return {} + return { + Address(self.contract_address + i): Account( + balance=self.contract_balance, + code=Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.POP( + Op.CALL( + Op.GAS, + self.contract_address + i - 1, + 0, + 0, + Op.CALLDATASIZE, + 0, + 0, + ) + ), + nonce=1, + ) + for i in range(1, self.call_depth - 1) + } + + def update_pre(self, base_pre: Dict[Address, Account]): + """Return the pre-state of the account.""" + while Address(self.contract_address) in base_pre: + self.contract_address += 0x100 + base_pre.update( + { + self.sender_account.address: Account(balance=self.sender_balance), + Address(self.contract_address): Account( + balance=self.contract_balance, code=self.contract_code, nonce=1 + ), + } + ) + base_pre.update(self.extra_contracts()) + + def valid_requests(self, current_minimum_fee: int) -> List[DepositRequest]: + """Return the list of deposit requests that should be included in the block.""" + return [d for d in self.requests if d.valid and d.value >= current_minimum_fee] diff --git a/tests/prague/eip6110_deposits/spec.py b/tests/prague/eip6110_deposits/spec.py new file mode 100644 index 00000000000..167e6f7a59d --- /dev/null +++ b/tests/prague/eip6110_deposits/spec.py @@ -0,0 +1,27 @@ +""" +Defines EIP-6110 specification constants and functions. +""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """ + Defines the reference spec version and git path. + """ + + git_path: str + version: str + + +ref_spec_6110 = ReferenceSpec("EIPS/eip-6110.md", "70a6ec21f62937caf665d98db2b41633e9287871") + + +@dataclass(frozen=True) +class Spec: + """ + Parameters from the EIP-6110 specifications as defined at + https://eips.ethereum.org/EIPS/eip-6110 + """ + + DEPOSIT_CONTRACT_ADDRESS = 0x00000000219AB540356CBB839CBE05303D7705FA diff --git a/tests/prague/eip6110_deposits/test_deposits.py b/tests/prague/eip6110_deposits/test_deposits.py new file mode 100644 index 00000000000..8b8f6eac171 --- /dev/null +++ b/tests/prague/eip6110_deposits/test_deposits.py @@ -0,0 +1,928 @@ +""" +abstract: Tests [EIP-6110: Supply validator deposits on chain](https://eips.ethereum.org/EIPS/eip-6110) + Test [EIP-6110: Supply validator deposits on chain](https://eips.ethereum.org/EIPS/eip-6110). +""" # noqa: E501 +from typing import Dict, List + +import pytest + +from ethereum_test_tools import ( + Account, + Address, + Block, + BlockchainTestFiller, + BlockException, + Environment, + Macros, +) +from ethereum_test_tools import Opcodes as Op + +from .helpers import ( + DepositContract, + DepositRequest, + DepositTransaction, + TestAccount1, + TestAccount2, +) +from .spec import ref_spec_6110 + +REFERENCE_SPEC_GIT_PATH = ref_spec_6110.git_path +REFERENCE_SPEC_VERSION = ref_spec_6110.version + +pytestmark = pytest.mark.valid_from("Prague") + + +@pytest.mark.parametrize( + "requests", + [ + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + id="single_deposit_from_eoa", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=120_000_000_000_000_000, + signature=0x03, + index=0x0, + ), + sender_balance=120_000_001_000_000_000 * 10**9, + ), + ], + id="single_deposit_from_eoa_huge_amount", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x1, + ), + ), + ], + id="multiple_deposit_from_same_eoa", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=i, + ), + ) + for i in range(200) + ], + id="multiple_deposit_from_same_eoa_high_count", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + sender_account=TestAccount1, + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x1, + ), + sender_account=TestAccount2, + ), + ], + id="multiple_deposit_from_different_eoa", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=999_999_999, + signature=0x03, + index=0x0, + ), + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + id="multiple_deposit_from_same_eoa_first_reverts", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=999_999_999, + signature=0x03, + index=0x0, + ), + ), + ], + id="multiple_deposit_from_same_eoa_last_reverts", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + # From traces, gas used by the first tx is 82,718 so reduce by one here + gas_limit=0x1431D, + valid=False, + ), + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + id="multiple_deposit_from_same_eoa_first_oog", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + # From traces, gas used by the second tx is 68,594 so reduce by one here + gas_limit=0x10BF1, + valid=False, + ), + ), + ], + id="multiple_deposit_from_same_eoa_last_oog", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + calldata_modifier=lambda _: b"", + valid=False, + ), + ), + ], + id="send_eth_from_eoa", + ), + pytest.param( + [ + DepositContract( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + id="single_deposit_from_contract", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x1, + ), + ], + ), + ], + id="multiple_deposits_from_contract", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=i, + ) + for i in range(1000) + ], + tx_gas_limit=60_000_000, + ), + ], + id="many_deposits_from_contract", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=999_999_999, + signature=0x03, + index=0x0, + valid=False, + ), + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ], + ), + ], + id="multiple_deposits_from_contract_first_reverts", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=999_999_999, + signature=0x03, + index=0x1, + valid=False, + ), + ], + ), + ], + id="multiple_deposits_from_contract_last_reverts", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + gas_limit=100, + index=0x0, + valid=False, + ), + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + gas_limit=1_000_000, + index=0x0, + ), + ], + ), + ], + id="multiple_deposits_from_contract_first_oog", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + gas_limit=1_000_000, + ), + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + gas_limit=100, + valid=False, + ), + ], + ), + ], + id="multiple_deposits_from_contract_last_oog", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + valid=False, + ), + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x1, + valid=False, + ), + ], + extra_code=Op.REVERT(0, 0), + ), + ], + id="multiple_deposits_from_contract_caller_reverts", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + valid=False, + ), + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x1, + valid=False, + ), + ], + extra_code=Macros.OOG(), + ), + ], + id="multiple_deposits_from_contract_caller_oog", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=i, + valid=False, + ) + for i in range(1000) + ], + tx_gas_limit=23_738_700, + ), + ], + id="many_deposits_from_contract_oog", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ], + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x1, + ), + ), + ], + id="single_deposit_from_contract_single_deposit_from_eoa", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x1, + ), + ], + ), + ], + id="single_deposit_from_eoa_single_deposit_from_contract", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x1, + ), + ], + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x2, + ), + ), + ], + id="single_deposit_from_contract_between_eoa_deposits", + ), + pytest.param( + [ + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ], + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x1, + ), + ), + DepositContract( + request=[ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x2, + ), + ], + ), + ], + id="single_deposit_from_eoa_between_contract_deposits", + ), + pytest.param( + [ + DepositContract( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + valid=False, + ), + call_type=Op.DELEGATECALL, + ), + ], + id="single_deposit_from_contract_delegatecall", + ), + pytest.param( + [ + DepositContract( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + valid=False, + ), + call_type=Op.STATICCALL, + ), + ], + id="single_deposit_from_contract_staticcall", + ), + pytest.param( + [ + DepositContract( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + valid=False, + ), + call_type=Op.CALLCODE, + ), + ], + id="single_deposit_from_contract_callcode", + ), + pytest.param( + [ + DepositContract( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + call_depth=3, + ), + ], + id="single_deposit_from_contract_call_depth_3", + ), + pytest.param( + [ + DepositContract( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + call_depth=1024, + tx_gas_limit=2_500_000_000_000, + ), + ], + id="single_deposit_from_contract_call_high_depth", + ), + # TODO: Send eth with the transaction to the contract + ], +) +def test_deposit( + blockchain_test: BlockchainTestFiller, + pre: Dict[Address, Account], + blocks: List[Block], +): + """ + Test making a deposit to the beacon chain deposit contract. + """ + blockchain_test( + genesis_environment=Environment(), + pre=pre, + post={}, + blocks=blocks, + ) + + +@pytest.mark.parametrize( + "requests,block_body_override_requests,exception", + [ + pytest.param( + [], + [ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ], + BlockException.INVALID_REQUESTS, + id="no_deposits_non_empty_requests_list", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + [], + BlockException.INVALID_REQUESTS, + id="single_deposit_empty_requests_list", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + [ + DepositRequest( + pubkey=0x02, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ) + ], + BlockException.INVALID_REQUESTS, + id="single_deposit_pubkey_mismatch", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + [ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x03, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ) + ], + BlockException.INVALID_REQUESTS, + id="single_deposit_credentials_mismatch", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + [ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=2_000_000_000, + signature=0x03, + index=0x0, + ) + ], + BlockException.INVALID_REQUESTS, + id="single_deposit_amount_mismatch", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + [ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x04, + index=0x0, + ) + ], + BlockException.INVALID_REQUESTS, + id="single_deposit_signature_mismatch", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + [ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x1, + ) + ], + BlockException.INVALID_REQUESTS, + id="single_deposit_index_mismatch", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x1, + ), + ), + ], + [ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x1, + ), + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ], + BlockException.INVALID_REQUESTS, + id="two_deposits_out_of_order", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + [ + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=1_000_000_000, + signature=0x03, + index=0x0, + ), + ], + BlockException.INVALID_REQUESTS, + id="single_deposit_duplicate_in_requests_list", + ), + ], +) +def test_deposit_negative( + blockchain_test: BlockchainTestFiller, + pre: Dict[Address, Account], + blocks: List[Block], +): + """ + Test producing a block with the incorrect deposits in the body of the block, + and/or Engine API payload. + """ + blockchain_test( + genesis_environment=Environment(), + pre=pre, + post={}, + blocks=blocks, + ) diff --git a/tests/prague/eip7002_el_triggerable_withdrawals/__init__.py b/tests/prague/eip7002_el_triggerable_withdrawals/__init__.py new file mode 100644 index 00000000000..899bbcbf570 --- /dev/null +++ b/tests/prague/eip7002_el_triggerable_withdrawals/__init__.py @@ -0,0 +1,3 @@ +""" +Cross-client EIP-7002 Tests +""" diff --git a/tests/prague/eip7002_el_triggerable_withdrawals/conftest.py b/tests/prague/eip7002_el_triggerable_withdrawals/conftest.py new file mode 100644 index 00000000000..39deca64e76 --- /dev/null +++ b/tests/prague/eip7002_el_triggerable_withdrawals/conftest.py @@ -0,0 +1,89 @@ +""" +Fixtures for the EIP-7002 deposit tests. +""" +from typing import Dict, List + +import pytest + +from ethereum_test_tools import Account, Address, Block, Header + +from .helpers import WithdrawalRequest, WithdrawalRequestInteractionBase +from .spec import Spec + + +@pytest.fixture +def included_requests( + blocks_withdrawal_requests: List[List[WithdrawalRequestInteractionBase]], +) -> List[List[WithdrawalRequest]]: + """ + Return the list of withdrawal requests that should be included in each block. + """ + excess_withdrawal_requests = 0 + carry_over_requests: List[WithdrawalRequest] = [] + per_block_included_requests: List[List[WithdrawalRequest]] = [] + for block_withdrawal_requests in blocks_withdrawal_requests: + # Get fee for the current block + current_minimum_fee = Spec.get_fee(excess_withdrawal_requests) + + # With the fee, get the valid withdrawal requests for the current block + current_block_requests = [] + for w in block_withdrawal_requests: + current_block_requests += w.valid_requests(current_minimum_fee) + + # Get the withdrawal requests that should be included in the block + pending_requests = carry_over_requests + current_block_requests + per_block_included_requests.append( + pending_requests[: Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK] + ) + carry_over_requests = pending_requests[Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK :] + + # Update the excess withdrawal requests + excess_withdrawal_requests = Spec.get_excess_withdrawal_requests( + excess_withdrawal_requests, + len(current_block_requests), + ) + return per_block_included_requests + + +@pytest.fixture +def pre( + blocks_withdrawal_requests: List[List[WithdrawalRequestInteractionBase]], +) -> Dict[Address, Account]: + """ + Initial state of the accounts. Every withdrawal transaction defines their own pre-state + requirements, and this fixture aggregates them all. + """ + pre: Dict[Address, Account] = {} + for requests in blocks_withdrawal_requests: + for d in requests: + d.update_pre(pre) + return pre + + +@pytest.fixture +def blocks( + blocks_withdrawal_requests: List[List[WithdrawalRequestInteractionBase]], + included_requests: List[List[WithdrawalRequest]], +) -> List[Block]: + """ + Return the list of blocks that should be included in the test. + """ + blocks: List[Block] = [] + address_nonce: Dict[Address, int] = {} + for i in range(len(blocks_withdrawal_requests)): + txs = [] + for r in blocks_withdrawal_requests[i]: + nonce = 0 + if r.sender_account.address in address_nonce: + nonce = address_nonce[r.sender_account.address] + txs.append(r.transaction(nonce)) + address_nonce[r.sender_account.address] = nonce + 1 + blocks.append( + Block( + txs=txs, + header_verify=Header( + requests_root=included_requests[i], + ), + ) + ) + return blocks diff --git a/tests/prague/eip7002_el_triggerable_withdrawals/helpers.py b/tests/prague/eip7002_el_triggerable_withdrawals/helpers.py new file mode 100644 index 00000000000..483e3f9de8d --- /dev/null +++ b/tests/prague/eip7002_el_triggerable_withdrawals/helpers.py @@ -0,0 +1,340 @@ +""" +Helpers for the EIP-7002 deposit tests. +""" +from dataclasses import dataclass, field +from functools import cached_property +from itertools import count +from typing import Callable, ClassVar, Dict, List + +from ethereum_test_tools import Account, Address +from ethereum_test_tools import Opcodes as Op +from ethereum_test_tools import ( + TestAddress, + TestAddress2, + TestPrivateKey, + TestPrivateKey2, + Transaction, +) +from ethereum_test_tools import WithdrawalRequest as WithdrawalRequestBase + +from .spec import Spec + + +@dataclass +class SenderAccount: + """Test sender account descriptor.""" + + address: Address + key: str + + +TestAccount1 = SenderAccount(TestAddress, TestPrivateKey) +TestAccount2 = SenderAccount(TestAddress2, TestPrivateKey2) + + +class WithdrawalRequest(WithdrawalRequestBase): + """ + Class used to describe a withdrawal request in a test. + """ + + fee: int = 0 + """ + Fee to be paid for the withdrawal request. + """ + valid: bool = True + """ + Whether the withdrawal request is valid or not. + """ + gas_limit: int = 1_000_000 + """ + Gas limit for the call. + """ + calldata_modifier: Callable[[bytes], bytes] = lambda x: x + """ + Calldata modifier function. + """ + + interaction_contract_address: ClassVar[Address] = Address( + Spec.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS + ) + + @property + def value(self) -> int: + """ + Returns the value of the withdrawal request. + """ + return self.fee + + @cached_property + def calldata(self) -> bytes: + """ + Returns the calldata needed to call the withdrawal request contract and make the + withdrawal. + """ + return self.calldata_modifier( + self.validator_public_key + self.amount.to_bytes(8, byteorder="big") + ) + + def with_source_address(self, source_address: Address) -> "WithdrawalRequest": + """ + Return a new instance of the withdrawal request with the source address set. + """ + return self.copy(source_address=source_address) + + +@dataclass(kw_only=True) +class WithdrawalRequestInteractionBase: + """ + Base class for all types of withdrawal transactions we want to test. + """ + + sender_balance: int = 32_000_000_000_000_000_000 * 100 + """ + Balance of the account that sends the transaction. + """ + sender_account: SenderAccount = field( + default_factory=lambda: SenderAccount(TestAddress, TestPrivateKey) + ) + """ + Account that will send the transaction. + """ + + def transaction(self, nonce: int) -> Transaction: + """Return a transaction for the withdrawal request.""" + raise NotImplementedError + + def update_pre(self, base_pre: Dict[Address, Account]): + """Return the pre-state of the account.""" + raise NotImplementedError + + def valid_requests(self, current_minimum_fee: int) -> List[WithdrawalRequest]: + """Return the list of withdrawal requests that should be valid in the block.""" + raise NotImplementedError + + +@dataclass(kw_only=True) +class WithdrawalRequestTransaction(WithdrawalRequestInteractionBase): + """Class used to describe a withdrawal request originated from an externally owned account.""" + + request: WithdrawalRequest + """ + Withdrawal request to be requested by the transaction. + """ + + def transaction(self, nonce: int) -> Transaction: + """Return a transaction for the withdrawal request.""" + return Transaction( + nonce=nonce, + gas_limit=self.request.gas_limit, + gas_price=0x07, + to=self.request.interaction_contract_address, + value=self.request.value, + data=self.request.calldata, + secret_key=self.sender_account.key, + ) + + def update_pre(self, base_pre: Dict[Address, Account]): + """Return the pre-state of the account.""" + base_pre.update( + { + self.sender_account.address: Account(balance=self.sender_balance), + } + ) + + def valid_requests(self, current_minimum_fee: int) -> List[WithdrawalRequest]: + """Return the list of withdrawal requests that are valid.""" + if self.request.valid and self.request.fee >= current_minimum_fee: + return [self.request.with_source_address(self.sender_account.address)] + return [] + + +@dataclass(kw_only=True) +class WithdrawalRequestContract(WithdrawalRequestInteractionBase): + """Class used to describe a deposit originated from a contract.""" + + request: List[WithdrawalRequest] | WithdrawalRequest + """ + Withdrawal request or list of withdrawal requests to be requested by the contract. + """ + + tx_gas_limit: int = 1_000_000 + """ + Gas limit for the transaction. + """ + + contract_balance: int = 32_000_000_000_000_000_000 * 100 + """ + Balance of the contract that will make the call to the pre-deploy contract. + """ + contract_address: int = 0x200 + """ + Address of the contract that will make the call to the pre-deploy contract. + """ + + call_type: Op = field(default_factory=lambda: Op.CALL) + """ + Type of call to be used to make the withdrawal request. + """ + call_depth: int = 2 + """ + Frame depth of the pre-deploy contract when it executes the call. + """ + extra_code: bytes = b"" + """ + Extra code to be added to the contract code. + """ + + @property + def requests(self) -> List[WithdrawalRequest]: + """Return the list of withdrawal requests.""" + if not isinstance(self.request, List): + return [self.request] + return self.request + + @property + def contract_code(self) -> bytes: + """Contract code used by the relay contract.""" + code = b"" + current_offset = 0 + for r in self.requests: + value_arg = [r.value] if self.call_type in (Op.CALL, Op.CALLCODE) else [] + code += Op.CALLDATACOPY(0, current_offset, len(r.calldata)) + Op.POP( + self.call_type( + Op.GAS if r.gas_limit == -1 else r.gas_limit, + r.interaction_contract_address, + *value_arg, + 0, + len(r.calldata), + 0, + 0, + ) + ) + current_offset += len(r.calldata) + return code + self.extra_code + + def transaction(self, nonce: int) -> Transaction: + """Return a transaction for the deposit request.""" + return Transaction( + nonce=nonce, + gas_limit=self.tx_gas_limit, + gas_price=0x07, + to=self.entry_address(), + value=0, + data=b"".join(r.calldata for r in self.requests), + secret_key=self.sender_account.key, + ) + + def entry_address(self) -> Address: + """Return the address of the contract entry point.""" + if self.call_depth == 2: + return Address(self.contract_address) + elif self.call_depth > 2: + return Address(self.contract_address + self.call_depth - 2) + raise ValueError("Invalid call depth") + + def extra_contracts(self) -> Dict[Address, Account]: + """Extra contracts used to simulate call depth.""" + if self.call_depth <= 2: + return {} + return { + Address(self.contract_address + i): Account( + balance=self.contract_balance, + code=Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.POP( + Op.CALL( + Op.GAS, + self.contract_address + i - 1, + 0, + 0, + Op.CALLDATASIZE, + 0, + 0, + ) + ), + nonce=1, + ) + for i in range(1, self.call_depth - 1) + } + + def update_pre(self, base_pre: Dict[Address, Account]): + """Return the pre-state of the account.""" + while Address(self.contract_address) in base_pre: + self.contract_address += 0x100 + base_pre.update( + { + self.sender_account.address: Account(balance=self.sender_balance), + Address(self.contract_address): Account( + balance=self.contract_balance, code=self.contract_code, nonce=1 + ), + } + ) + base_pre.update(self.extra_contracts()) + + def valid_requests(self, current_minimum_fee: int) -> List[WithdrawalRequest]: + """Return the list of withdrawal requests that are valid.""" + valid_requests: List[WithdrawalRequest] = [] + for r in self.requests: + if r.valid and r.value >= current_minimum_fee: + valid_requests.append(r.with_source_address(Address(self.contract_address))) + return valid_requests + + +def get_n_fee_increments(n: int) -> List[int]: + """ + Get the first N excess withdrawal requests that increase the fee. + """ + excess_withdrawal_requests_counts = [] + last_fee = 1 + for i in count(0): + if Spec.get_fee(i) > last_fee: + excess_withdrawal_requests_counts.append(i) + last_fee = Spec.get_fee(i) + if len(excess_withdrawal_requests_counts) == n: + break + return excess_withdrawal_requests_counts + + +def get_n_fee_increment_blocks(n: int) -> List[List[WithdrawalRequestContract]]: + """ + Return N blocks that should be included in the test such that each subsequent block has an + increasing fee for the withdrawal requests. + + This is done by calculating the number of withdrawals required to reach the next fee increment + and creating a block with that number of withdrawal requests plus the number of withdrawals + required to reach the target. + """ + blocks = [] + previous_excess = 0 + nonce = count(0) + withdrawal_index = 0 + previous_fee = 0 + for required_excess_withdrawals in get_n_fee_increments(n): + withdrawals_required = ( + required_excess_withdrawals + + Spec.TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK + - previous_excess + ) + contract_address = next(nonce) + fee = Spec.get_fee(previous_excess) + assert fee > previous_fee + blocks.append( + [ + WithdrawalRequestContract( + request=[ + WithdrawalRequest( + validator_public_key=i, + amount=0, + fee=fee, + ) + for i in range(withdrawal_index, withdrawal_index + withdrawals_required) + ], + # Increment the contract address to avoid overwriting the previous one + contract_address=0x200 + (contract_address * 0x100), + ) + ], + ) + previous_fee = fee + withdrawal_index += withdrawals_required + previous_excess = required_excess_withdrawals + + return blocks diff --git a/tests/prague/eip7002_el_triggerable_withdrawals/spec.py b/tests/prague/eip7002_el_triggerable_withdrawals/spec.py new file mode 100644 index 00000000000..f56381e4378 --- /dev/null +++ b/tests/prague/eip7002_el_triggerable_withdrawals/spec.py @@ -0,0 +1,89 @@ +""" +Common procedures to test +[EIP-7002: Execution layer triggerable withdrawals](https://eips.ethereum.org/EIPS/eip-7002) +""" # noqa: E501 + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """ + Defines the reference spec version and git path. + """ + + git_path: str + version: str + + +ref_spec_7002 = ReferenceSpec("EIPS/eip-7002.md", "e5af719767e789c88c0e063406c6557c8f53cfba") + + +# Constants +@dataclass(frozen=True) +class Spec: + """ + Parameters from the EIP-7002 specifications as defined at + https://eips.ethereum.org/EIPS/eip-7002#configuration + + If the parameter is not currently used within the tests, it is commented + out. + """ + + WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS = 0x00A3CA265EBCB825B45F985A16CEFB49958CE017 + SYSTEM_ADDRESS = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE + + EXCESS_WITHDRAWAL_REQUESTS_STORAGE_SLOT = 0 + WITHDRAWAL_REQUEST_COUNT_STORAGE_SLOT = 1 + WITHDRAWAL_REQUEST_QUEUE_HEAD_STORAGE_SLOT = ( + 2 # Pointer to head of the withdrawal request message queue + ) + WITHDRAWAL_REQUEST_QUEUE_TAIL_STORAGE_SLOT = ( + 3 # Pointer to the tail of the withdrawal request message queue + ) + WITHDRAWAL_REQUEST_QUEUE_STORAGE_OFFSET = ( + 4 # The start memory slot of the in-state withdrawal request message queue + ) + MAX_WITHDRAWAL_REQUESTS_PER_BLOCK = ( + 16 # Maximum number of withdrawal requests that can be de-queued into a block + ) + TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK = 2 + MIN_WITHDRAWAL_REQUEST_FEE = 1 + WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION = 17 + EXCESS_RETURN_GAS_STIPEND = 2300 + + MAX_AMOUNT = 2**64 - 1 + + @staticmethod + def fake_exponential(factor: int, numerator: int, denominator: int) -> int: + """ + Used to calculate the withdrawal request fee. + """ + i = 1 + output = 0 + numerator_accumulator = factor * denominator + while numerator_accumulator > 0: + output += numerator_accumulator + numerator_accumulator = (numerator_accumulator * numerator) // (denominator * i) + i += 1 + return output // denominator + + @staticmethod + def get_fee(excess_withdrawal_requests: int) -> int: + """ + Calculate the fee for the excess withdrawal requests. + """ + return Spec.fake_exponential( + Spec.MIN_WITHDRAWAL_REQUEST_FEE, + excess_withdrawal_requests, + Spec.WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION, + ) + + @staticmethod + def get_excess_withdrawal_requests(previous_excess: int, count: int) -> int: + """ + Calculate the new excess withdrawal requests. + """ + if previous_excess + count > Spec.TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK: + return previous_excess + count - Spec.TARGET_WITHDRAWAL_REQUESTS_PER_BLOCK + return 0 diff --git a/tests/prague/eip7002_el_triggerable_withdrawals/test_withdrawal_requests.py b/tests/prague/eip7002_el_triggerable_withdrawals/test_withdrawal_requests.py new file mode 100644 index 00000000000..bd9bbd3bf2f --- /dev/null +++ b/tests/prague/eip7002_el_triggerable_withdrawals/test_withdrawal_requests.py @@ -0,0 +1,694 @@ +""" +abstract: Tests [EIP-7002: Execution layer triggerable withdrawals](https://eips.ethereum.org/EIPS/eip-7002) + Test execution layer triggered exits [EIP-7002: Execution layer triggerable withdrawals](https://eips.ethereum.org/EIPS/eip-7002) + +""" # noqa: E501 + +from typing import Dict, List + +import pytest + +from ethereum_test_tools import ( + Account, + Address, + Block, + BlockchainTestFiller, + BlockException, + Environment, + Header, + Macros, +) +from ethereum_test_tools import Opcodes as Op +from ethereum_test_tools import TestAddress, TestAddress2 + +from .helpers import ( + TestAccount2, + WithdrawalRequest, + WithdrawalRequestContract, + WithdrawalRequestInteractionBase, + WithdrawalRequestTransaction, + get_n_fee_increment_blocks, +) +from .spec import Spec, ref_spec_7002 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7002.git_path +REFERENCE_SPEC_VERSION = ref_spec_7002.version + +pytestmark = pytest.mark.valid_from("Prague") + + +@pytest.mark.parametrize( + "blocks_withdrawal_requests", + [ + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + ], + ], + id="single_block_single_withdrawal_request_from_eoa", + ), + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=0, + ), + ), + ], + ], + id="single_block_single_withdrawal_request_from_eoa_insufficient_fee", + ), + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + calldata_modifier=lambda x: x[:-1], + valid=False, + ), + ), + ], + ], + id="single_block_single_withdrawal_request_from_eoa_input_too_short", + ), + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + calldata_modifier=lambda x: x + b"\x00", + valid=False, + ), + ), + ], + ], + id="single_block_single_withdrawal_request_from_eoa_input_too_long", + ), + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x02, + amount=Spec.MAX_AMOUNT - 1, + fee=Spec.get_fee(0), + ), + ), + ], + ], + id="single_block_multiple_withdrawal_request_from_same_eoa", + ), + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x02, + amount=Spec.MAX_AMOUNT - 1, + fee=Spec.get_fee(0), + ), + sender_account=TestAccount2, + ), + ], + ], + id="single_block_multiple_withdrawal_request_from_different_eoa", + ), + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=i + 1, + amount=0 if i % 2 == 0 else Spec.MAX_AMOUNT, + fee=Spec.get_fee(0), + ), + ) + for i in range(Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK) + ], + ], + id="single_block_max_withdrawal_requests_from_eoa", + ), + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=0, + ), + ), + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x02, + amount=Spec.MAX_AMOUNT - 1, + fee=Spec.get_fee(0), + ), + ), + ], + ], + id="single_block_multiple_withdrawal_request_first_reverts", + ), + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x02, + amount=Spec.MAX_AMOUNT - 1, + fee=0, + ), + ), + ], + ], + id="single_block_multiple_withdrawal_request_last_reverts", + ), + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + # Value obtained from trace minus one + gas_limit=114_247 - 1, + valid=False, + ), + ), + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x02, + amount=0, + fee=Spec.get_fee(0), + ), + ), + ], + ], + id="single_block_multiple_withdrawal_request_first_oog", + ), + pytest.param( + [ + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x02, + amount=0, + fee=Spec.get_fee(0), + # Value obtained from trace minus one + gas_limit=80_047 - 1, + valid=False, + ), + ), + ], + ], + id="single_block_multiple_withdrawal_request_last_oog", + ), + pytest.param( + [ + # Block 1 + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=i + 1, + amount=0 if i % 2 == 0 else Spec.MAX_AMOUNT, + fee=Spec.get_fee(0), + ), + ) + for i in range(Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK * 2) + ], + # Block 2, no new withdrawal requests, but queued requests from previous block + [], + # Block 3, no new nor queued withdrawal requests + [], + ], + id="multiple_block_above_max_withdrawal_requests_from_eoa", + ), + pytest.param( + [ + [ + WithdrawalRequestContract( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + ], + ], + id="single_block_single_withdrawal_request_from_contract", + ), + pytest.param( + [ + [ + WithdrawalRequestContract( + request=[ + WithdrawalRequest( + validator_public_key=i + 1, + amount=Spec.MAX_AMOUNT - 1 if i % 2 == 0 else 0, + fee=Spec.get_fee(0), + ) + for i in range(Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK) + ], + ), + ], + ], + id="single_block_multiple_withdrawal_requests_from_contract", + ), + pytest.param( + [ + [ + WithdrawalRequestContract( + request=[ + WithdrawalRequest( + validator_public_key=1, + amount=Spec.MAX_AMOUNT, + fee=0, + ) + ] + + [ + WithdrawalRequest( + validator_public_key=i + 1, + amount=Spec.MAX_AMOUNT - 1 if i % 2 == 0 else 0, + fee=Spec.get_fee(0), + ) + for i in range(1, Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK) + ], + ), + ], + ], + id="single_block_multiple_withdrawal_requests_from_contract_first_reverts", + ), + pytest.param( + [ + [ + WithdrawalRequestContract( + request=[ + WithdrawalRequest( + validator_public_key=i + 1, + amount=Spec.MAX_AMOUNT - 1 if i % 2 == 0 else 0, + fee=Spec.get_fee(0), + ) + for i in range(Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK - 1) + ] + + [ + WithdrawalRequest( + validator_public_key=Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK, + amount=Spec.MAX_AMOUNT - 1 + if (Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK - 1) % 2 == 0 + else 0, + fee=0, + ) + ], + ), + ], + ], + id="single_block_multiple_withdrawal_requests_from_contract_last_reverts", + ), + pytest.param( + [ + [ + WithdrawalRequestContract( + request=[ + WithdrawalRequest( + validator_public_key=1, + amount=Spec.MAX_AMOUNT - 1, + gas_limit=100, + fee=Spec.get_fee(0), + valid=False, + ) + ] + + [ + WithdrawalRequest( + validator_public_key=i + 1, + amount=Spec.MAX_AMOUNT - 1 if i % 2 == 0 else 0, + gas_limit=1_000_000, + fee=Spec.get_fee(0), + valid=True, + ) + for i in range(1, Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK) + ], + ), + ], + ], + id="single_block_multiple_withdrawal_requests_from_contract_first_oog", + ), + pytest.param( + [ + [ + WithdrawalRequestContract( + request=[ + WithdrawalRequest( + validator_public_key=i + 1, + amount=Spec.MAX_AMOUNT - 1 if i % 2 == 0 else 0, + fee=Spec.get_fee(0), + gas_limit=1_000_000, + valid=True, + ) + for i in range(Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK) + ] + + [ + WithdrawalRequest( + validator_public_key=Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK, + amount=Spec.MAX_AMOUNT - 1, + gas_limit=100, + fee=Spec.get_fee(0), + valid=False, + ) + ], + ), + ], + ], + id="single_block_multiple_withdrawal_requests_from_contract_last_oog", + ), + pytest.param( + [ + [ + WithdrawalRequestContract( + request=[ + WithdrawalRequest( + validator_public_key=i + 1, + amount=Spec.MAX_AMOUNT - 1 if i % 2 == 0 else 0, + fee=Spec.get_fee(0), + valid=False, + ) + for i in range(Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK) + ], + extra_code=Op.REVERT(0, 0), + ), + ], + ], + id="single_block_multiple_withdrawal_requests_from_contract_caller_reverts", + ), + pytest.param( + [ + [ + WithdrawalRequestContract( + request=[ + WithdrawalRequest( + validator_public_key=i + 1, + amount=Spec.MAX_AMOUNT - 1 if i % 2 == 0 else 0, + fee=Spec.get_fee(0), + valid=False, + ) + for i in range(Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK) + ], + extra_code=Macros.OOG(), + ), + ], + ], + id="single_block_multiple_withdrawal_requests_from_contract_caller_oog", + ), + pytest.param( + # Test the first 50 fee increments + get_n_fee_increment_blocks(50), + id="multiple_block_fee_increments", + ), + pytest.param( + [ + [ + WithdrawalRequestContract( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + valid=False, + ), + call_type=Op.DELEGATECALL, + ), + WithdrawalRequestContract( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + valid=False, + ), + call_type=Op.STATICCALL, + ), + WithdrawalRequestContract( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + valid=False, + ), + call_type=Op.CALLCODE, + ), + ], + ], + id="single_block_single_withdrawal_request_delegatecall_staticcall_callcode", + ), + ], +) +def test_withdrawal_requests( + blockchain_test: BlockchainTestFiller, + blocks: List[Block], + pre: Dict[Address, Account], +): + """ + Test making a withdrawal request to the beacon chain. + """ + blockchain_test( + genesis_environment=Environment(), + pre=pre, + post={}, + blocks=blocks, + ) + + +@pytest.mark.parametrize( + "requests,block_body_override_requests,exception", + [ + pytest.param( + [], + [ + WithdrawalRequest( + validator_public_key=0x01, + amount=0, + source_address=Address(0), + ), + ], + BlockException.INVALID_REQUESTS, + id="no_withdrawals_non_empty_requests_list", + ), + pytest.param( + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + ], + [], + BlockException.INVALID_REQUESTS, + id="single_withdrawal_request_empty_requests_list", + ), + pytest.param( + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + ], + [ + WithdrawalRequest( + validator_public_key=0x02, + amount=0, + source_address=TestAddress, + ) + ], + BlockException.INVALID_REQUESTS, + id="single_withdrawal_request_public_key_mismatch", + ), + pytest.param( + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + ], + [ + WithdrawalRequest( + validator_public_key=0x01, + amount=1, + source_address=TestAddress, + ) + ], + BlockException.INVALID_REQUESTS, + id="single_withdrawal_request_amount_mismatch", + ), + pytest.param( + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + ], + [ + WithdrawalRequest( + validator_public_key=0x01, + amount=0, + source_address=TestAddress2, + ) + ], + BlockException.INVALID_REQUESTS, + id="single_withdrawal_request_source_address_mismatch", + ), + pytest.param( + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x02, + amount=0, + fee=Spec.get_fee(0), + ), + ), + ], + [ + WithdrawalRequest( + validator_public_key=0x02, + amount=0, + source_address=TestAddress, + ), + WithdrawalRequest( + validator_public_key=0x01, + amount=0, + source_address=TestAddress, + ), + ], + BlockException.INVALID_REQUESTS, + id="two_withdrawal_requests_out_of_order", + ), + pytest.param( + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=Spec.get_fee(0), + ), + ), + ], + [ + WithdrawalRequest( + validator_public_key=0x01, + amount=0, + source_address=TestAddress, + ), + WithdrawalRequest( + validator_public_key=0x01, + amount=0, + source_address=TestAddress, + ), + ], + BlockException.INVALID_REQUESTS, + id="single_withdrawal_requests_duplicate_in_requests_list", + ), + ], +) +def test_withdrawal_requests_negative( + blockchain_test: BlockchainTestFiller, + requests: List[WithdrawalRequestInteractionBase], + block_body_override_requests: List[WithdrawalRequest], + exception: BlockException, +): + """ + Test blocks where the requests list and the actual withdrawal requests that happened in the + block's transactions do not match. + """ + # No previous block so fee is the base + fee = 1 + current_block_requests = [] + for w in requests: + current_block_requests += w.valid_requests(fee) + included_requests = current_block_requests[: Spec.MAX_WITHDRAWAL_REQUESTS_PER_BLOCK] + + pre: Dict[Address, Account] = {} + for d in requests: + d.update_pre(pre) + + address_nonce: Dict[Address, int] = {} + txs = [] + for r in requests: + nonce = 0 + if r.sender_account.address in address_nonce: + nonce = address_nonce[r.sender_account.address] + txs.append(r.transaction(nonce)) + address_nonce[r.sender_account.address] = nonce + 1 + blockchain_test( + genesis_environment=Environment(), + pre=pre, + post={}, + blocks=[ + Block( + txs=txs, + header_verify=Header( + requests_root=included_requests, + ), + requests=block_body_override_requests, + exception=exception, + ) + ], + ) diff --git a/tests/prague/eip7685_general_purpose_el_requests/__init__.py b/tests/prague/eip7685_general_purpose_el_requests/__init__.py new file mode 100644 index 00000000000..8d782b5a612 --- /dev/null +++ b/tests/prague/eip7685_general_purpose_el_requests/__init__.py @@ -0,0 +1,3 @@ +""" +Cross-client EIP-7685 Tests +""" diff --git a/tests/prague/eip7685_general_purpose_el_requests/conftest.py b/tests/prague/eip7685_general_purpose_el_requests/conftest.py new file mode 100644 index 00000000000..16cb104a7e9 --- /dev/null +++ b/tests/prague/eip7685_general_purpose_el_requests/conftest.py @@ -0,0 +1,87 @@ +""" +Fixtures for the EIP-7685 deposit tests. +""" + +from typing import Dict, List + +import pytest + +from ethereum_test_tools import Account, Address, Block, BlockException, Header, Transaction + +from ..eip6110_deposits.helpers import DepositInteractionBase, DepositRequest +from ..eip7002_el_triggerable_withdrawals.helpers import ( + WithdrawalRequest, + WithdrawalRequestInteractionBase, +) + + +@pytest.fixture +def pre( + requests: List[DepositInteractionBase | WithdrawalRequestInteractionBase], +) -> Dict[Address, Account]: + """ + Initial state of the accounts. Every deposit transaction defines their own pre-state + requirements, and this fixture aggregates them all. + """ + pre: Dict[Address, Account] = {} + for d in requests: + d.update_pre(pre) + return pre + + +@pytest.fixture +def txs( + requests: List[DepositInteractionBase | WithdrawalRequestInteractionBase], +) -> List[Transaction]: + """List of transactions to include in the block.""" + address_nonce: Dict[Address, int] = {} + txs = [] + for r in requests: + nonce = 0 + if r.sender_account.address in address_nonce: + nonce = address_nonce[r.sender_account.address] + txs.append(r.transaction(nonce)) + address_nonce[r.sender_account.address] = nonce + 1 + return txs + + +@pytest.fixture +def block_body_override_requests() -> List[DepositRequest] | None: + """List of requests that overwrite the requests in the header. None by default.""" + return None + + +@pytest.fixture +def exception() -> BlockException | None: + """Block exception expected by the tests. None by default.""" + return None + + +@pytest.fixture +def blocks( + requests: List[DepositInteractionBase | WithdrawalRequestInteractionBase], + block_body_override_requests: List[DepositRequest | WithdrawalRequest] | None, + txs: List[Transaction], + exception: BlockException | None, +) -> List[Block]: + """List of blocks that comprise the test.""" + included_deposit_requests = [] + included_withdrawal_requests = [] + # Single block therefore base fee + withdrawal_request_fee = 1 + for r in requests: + if isinstance(r, DepositInteractionBase): + included_deposit_requests += r.valid_requests(10**18) + elif isinstance(r, WithdrawalRequestInteractionBase): + included_withdrawal_requests += r.valid_requests(withdrawal_request_fee) + + return [ + Block( + txs=txs, + header_verify=Header( + requests_root=included_deposit_requests + included_withdrawal_requests, + ), + requests=block_body_override_requests, + exception=exception, + ) + ] diff --git a/tests/prague/eip7685_general_purpose_el_requests/spec.py b/tests/prague/eip7685_general_purpose_el_requests/spec.py new file mode 100644 index 00000000000..d4b7d6dc0c5 --- /dev/null +++ b/tests/prague/eip7685_general_purpose_el_requests/spec.py @@ -0,0 +1,19 @@ +""" +Common procedures to test +[EIP-7685: General purpose execution layer requests](https://eips.ethereum.org/EIPS/eip-7685) +""" # noqa: E501 + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ReferenceSpec: + """ + Defines the reference spec version and git path. + """ + + git_path: str + version: str + + +ref_spec_7685 = ReferenceSpec("EIPS/eip-7685.md", "52a260582376476e658b1dda60864bcac3cf5e1a") diff --git a/tests/prague/eip7685_general_purpose_el_requests/test_deposits_withdrawals.py b/tests/prague/eip7685_general_purpose_el_requests/test_deposits_withdrawals.py new file mode 100644 index 00000000000..b7356fdce89 --- /dev/null +++ b/tests/prague/eip7685_general_purpose_el_requests/test_deposits_withdrawals.py @@ -0,0 +1,375 @@ +""" +abstract: Tests [EIP-7685: General purpose execution layer requests](https://eips.ethereum.org/EIPS/eip-7685) + Cross testing for withdrawal and deposit request for [EIP-7685: General purpose execution layer requests](https://eips.ethereum.org/EIPS/eip-7685) + +""" # noqa: E501 + +from typing import Dict, List + +import pytest + +from ethereum_test_tools import ( + Account, + Address, + Block, + BlockchainTestFiller, + BlockException, + Environment, + Header, +) +from ethereum_test_tools import Opcodes as Op +from ethereum_test_tools import TestAddress, Transaction + +from ..eip6110_deposits.helpers import DepositContract, DepositRequest, DepositTransaction +from ..eip6110_deposits.spec import Spec as Spec_EIP6110 +from ..eip7002_el_triggerable_withdrawals.helpers import ( + WithdrawalRequest, + WithdrawalRequestContract, + WithdrawalRequestTransaction, +) +from ..eip7002_el_triggerable_withdrawals.spec import Spec as Spec_EIP7002 +from .spec import ref_spec_7685 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7685.git_path +REFERENCE_SPEC_VERSION = ref_spec_7685.version + +pytestmark = pytest.mark.valid_from("Prague") + + +@pytest.mark.parametrize( + "requests", + [ + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=1, + ), + ), + ], + id="single_deposit_from_eoa_single_withdrawal_from_eoa", + ), + pytest.param( + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=1, + ), + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + id="single_withdrawal_from_eoa_single_deposit_from_eoa", + ), + pytest.param( + [ + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=1, + ), + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x1, + ), + ), + ], + id="two_deposits_from_eoa_single_withdrawal_from_eoa", + ), + pytest.param( + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=1, + ), + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=1, + fee=1, + ), + ), + ], + id="two_withdrawals_from_eoa_single_deposit_from_eoa", + ), + pytest.param( + [ + DepositContract( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + WithdrawalRequestContract( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=1, + ), + ), + ], + id="single_deposit_from_contract_single_withdrawal_from_contract", + ), + pytest.param( + [ + WithdrawalRequestContract( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=1, + ), + ), + DepositContract( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + id="single_withdrawal_from_contract_single_deposit_from_contract", + ), + # TODO: Deposit and withdrawal in the same transaction + ], +) +def test_valid_deposit_withdrawal_requests( + blockchain_test: BlockchainTestFiller, + pre: Dict[Address, Account], + blocks: List[Block], +): + """ + Test making a deposit to the beacon chain deposit contract and a withdrawal in the same block. + """ + blockchain_test( + genesis_environment=Environment(), + pre=pre, + post={}, + blocks=blocks, + ) + + +@pytest.mark.parametrize( + "deposit_first", + [ + pytest.param(True, id="deposit_first"), + pytest.param(False, id="withdrawal_first"), + ], +) +def test_valid_deposit_withdrawal_request_from_same_tx( + blockchain_test: BlockchainTestFiller, + deposit_first: bool, +): + """ + Test making a deposit to the beacon chain deposit contract and a withdrawal in the same tx. + """ + contract_address = 0x200 + withdrawal_request_fee = 1 + deposit_request = DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ) + withdrawal_request = WithdrawalRequest( + validator_public_key=0x01, + amount=0, + source_address=contract_address, + ) + if deposit_first: + calldata = deposit_request.calldata + withdrawal_request.calldata + contract_code = ( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.POP( + Op.CALL( + Op.GAS, + Spec_EIP6110.DEPOSIT_CONTRACT_ADDRESS, + deposit_request.value, + 0, + len(deposit_request.calldata), + 0, + 0, + ) + ) + + Op.POP( + Op.CALL( + Op.GAS, + Spec_EIP7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + withdrawal_request_fee, + len(deposit_request.calldata), + len(withdrawal_request.calldata), + 0, + 0, + ) + ) + ) + else: + calldata = withdrawal_request.calldata + deposit_request.calldata + contract_code = ( + Op.CALLDATACOPY(0, 0, Op.CALLDATASIZE) + + Op.POP( + Op.CALL( + Op.GAS, + Spec_EIP7002.WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + withdrawal_request_fee, + 0, + len(withdrawal_request.calldata), + 0, + 0, + ) + ) + + Op.POP( + Op.CALL( + Op.GAS, + Spec_EIP6110.DEPOSIT_CONTRACT_ADDRESS, + deposit_request.value, + len(withdrawal_request.calldata), + len(deposit_request.calldata), + 0, + 0, + ) + ) + ) + + pre = { + TestAddress: Account( + balance=10**18, + ), + contract_address: Account( + code=contract_code, + balance=deposit_request.value + withdrawal_request_fee, + ), + } + + tx = Transaction( + nonce=0, + gas_limit=1_000_000, + gas_price=0x07, + to=contract_address, + value=0, + data=calldata, + ) + + block = Block( + txs=[tx], + header_verify=Header( + requests_root=[deposit_request, withdrawal_request], + ), + ) + + blockchain_test( + genesis_environment=Environment(), + pre=pre, + post={}, + blocks=[block], + ) + + +@pytest.mark.parametrize( + "requests,block_body_override_requests,exception", + [ + pytest.param( + [ + WithdrawalRequestTransaction( + request=WithdrawalRequest( + validator_public_key=0x01, + amount=0, + fee=1, + ), + ), + DepositTransaction( + request=DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ), + ], + [ + WithdrawalRequest( + validator_public_key=0x01, + amount=0, + source_address=TestAddress, + ), + DepositRequest( + pubkey=0x01, + withdrawal_credentials=0x02, + amount=32_000_000_000, + signature=0x03, + index=0x0, + ), + ], + # TODO: on the Engine API, the issue should be detected as an invalid block hash + BlockException.INVALID_REQUESTS, + id="single_deposit_from_eoa_single_withdrawal_from_eoa_incorrect_order", + ), + ], +) +def test_invalid_deposit_withdrawal_requests( + blockchain_test: BlockchainTestFiller, + pre: Dict[Address, Account], + blocks: List[Block], +): + """ + Negative testing for deposits and withdrawals in the same block. + """ + blockchain_test( + genesis_environment=Environment(), + pre=pre, + post={}, + blocks=blocks, + ) diff --git a/whitelist.txt b/whitelist.txt index 6a6006df91a..da33115fa33 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -1,4 +1,6 @@ 0xaa +Account1 +Account2 acl addr address @@ -95,6 +97,9 @@ eip eip3540 eips EIPs +eip6110 +eip7002 +el endianness EngineAPI enum @@ -250,10 +255,12 @@ ppas pre Pre precompile +predeploy prepend PrevRandao prestateTracer programmatically +pubkey px py pydantic @@ -332,6 +339,7 @@ Tox traceback TransactionException trie +triggerable tstorage tx txs