From e597ce34b089b41301b80bf16e84fef2a04e3cc0 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 8 Dec 2025 15:22:07 -0700 Subject: [PATCH 01/51] feat(spec-specs): create Amsterdam from Osaka --- src/ethereum/forks/amsterdam/__init__.py | 4 + src/ethereum/forks/amsterdam/blocks.py | 405 +++++++ src/ethereum/forks/amsterdam/bloom.py | 87 ++ src/ethereum/forks/amsterdam/exceptions.py | 131 ++ src/ethereum/forks/amsterdam/fork.py | 1061 +++++++++++++++++ src/ethereum/forks/amsterdam/fork_types.py | 78 ++ src/ethereum/forks/amsterdam/requests.py | 191 +++ src/ethereum/forks/amsterdam/state.py | 667 +++++++++++ src/ethereum/forks/amsterdam/transactions.py | 887 ++++++++++++++ src/ethereum/forks/amsterdam/trie.py | 508 ++++++++ .../forks/amsterdam/utils/__init__.py | 3 + src/ethereum/forks/amsterdam/utils/address.py | 94 ++ .../forks/amsterdam/utils/hexadecimal.py | 55 + src/ethereum/forks/amsterdam/utils/message.py | 90 ++ src/ethereum/forks/amsterdam/vm/__init__.py | 191 +++ .../forks/amsterdam/vm/eoa_delegation.py | 211 ++++ src/ethereum/forks/amsterdam/vm/exceptions.py | 139 +++ src/ethereum/forks/amsterdam/vm/gas.py | 396 ++++++ .../amsterdam/vm/instructions/__init__.py | 367 ++++++ .../amsterdam/vm/instructions/arithmetic.py | 373 ++++++ .../amsterdam/vm/instructions/bitwise.py | 274 +++++ .../forks/amsterdam/vm/instructions/block.py | 261 ++++ .../amsterdam/vm/instructions/comparison.py | 177 +++ .../amsterdam/vm/instructions/control_flow.py | 171 +++ .../amsterdam/vm/instructions/environment.py | 600 ++++++++++ .../forks/amsterdam/vm/instructions/keccak.py | 63 + .../forks/amsterdam/vm/instructions/log.py | 88 ++ .../forks/amsterdam/vm/instructions/memory.py | 177 +++ .../forks/amsterdam/vm/instructions/stack.py | 208 ++++ .../amsterdam/vm/instructions/storage.py | 188 +++ .../forks/amsterdam/vm/instructions/system.py | 751 ++++++++++++ .../forks/amsterdam/vm/interpreter.py | 324 +++++ src/ethereum/forks/amsterdam/vm/memory.py | 83 ++ .../vm/precompiled_contracts/__init__.py | 55 + .../vm/precompiled_contracts/alt_bn128.py | 230 ++++ .../vm/precompiled_contracts/blake2f.py | 42 + .../bls12_381/__init__.py | 622 ++++++++++ .../bls12_381/bls12_381_g1.py | 151 +++ .../bls12_381/bls12_381_g2.py | 153 +++ .../bls12_381/bls12_381_pairing.py | 69 ++ .../vm/precompiled_contracts/ecrecover.py | 64 + .../vm/precompiled_contracts/identity.py | 39 + .../vm/precompiled_contracts/mapping.py | 77 ++ .../vm/precompiled_contracts/modexp.py | 175 +++ .../vm/precompiled_contracts/p256verify.py | 89 ++ .../precompiled_contracts/point_evaluation.py | 72 ++ .../vm/precompiled_contracts/ripemd160.py | 44 + .../vm/precompiled_contracts/sha256.py | 41 + src/ethereum/forks/amsterdam/vm/runtime.py | 69 ++ src/ethereum/forks/amsterdam/vm/stack.py | 58 + 50 files changed, 11353 insertions(+) create mode 100644 src/ethereum/forks/amsterdam/__init__.py create mode 100644 src/ethereum/forks/amsterdam/blocks.py create mode 100644 src/ethereum/forks/amsterdam/bloom.py create mode 100644 src/ethereum/forks/amsterdam/exceptions.py create mode 100644 src/ethereum/forks/amsterdam/fork.py create mode 100644 src/ethereum/forks/amsterdam/fork_types.py create mode 100644 src/ethereum/forks/amsterdam/requests.py create mode 100644 src/ethereum/forks/amsterdam/state.py create mode 100644 src/ethereum/forks/amsterdam/transactions.py create mode 100644 src/ethereum/forks/amsterdam/trie.py create mode 100644 src/ethereum/forks/amsterdam/utils/__init__.py create mode 100644 src/ethereum/forks/amsterdam/utils/address.py create mode 100644 src/ethereum/forks/amsterdam/utils/hexadecimal.py create mode 100644 src/ethereum/forks/amsterdam/utils/message.py create mode 100644 src/ethereum/forks/amsterdam/vm/__init__.py create mode 100644 src/ethereum/forks/amsterdam/vm/eoa_delegation.py create mode 100644 src/ethereum/forks/amsterdam/vm/exceptions.py create mode 100644 src/ethereum/forks/amsterdam/vm/gas.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/__init__.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/arithmetic.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/bitwise.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/block.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/comparison.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/control_flow.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/environment.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/keccak.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/log.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/memory.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/stack.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/storage.py create mode 100644 src/ethereum/forks/amsterdam/vm/instructions/system.py create mode 100644 src/ethereum/forks/amsterdam/vm/interpreter.py create mode 100644 src/ethereum/forks/amsterdam/vm/memory.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/__init__.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/alt_bn128.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/blake2f.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/__init__.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_g1.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_g2.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_pairing.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/ecrecover.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/identity.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/mapping.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/modexp.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/p256verify.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/point_evaluation.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/ripemd160.py create mode 100644 src/ethereum/forks/amsterdam/vm/precompiled_contracts/sha256.py create mode 100644 src/ethereum/forks/amsterdam/vm/runtime.py create mode 100644 src/ethereum/forks/amsterdam/vm/stack.py diff --git a/src/ethereum/forks/amsterdam/__init__.py b/src/ethereum/forks/amsterdam/__init__.py new file mode 100644 index 0000000000..ecba6c2f8f --- /dev/null +++ b/src/ethereum/forks/amsterdam/__init__.py @@ -0,0 +1,4 @@ + +from ethereum.fork_criteria import Unscheduled, ForkCriteria + +FORK_CRITERIA: ForkCriteria = Unscheduled(order_index=0) diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py new file mode 100644 index 0000000000..ba3c27e9e3 --- /dev/null +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -0,0 +1,405 @@ +""" +A `Block` is a single link in the chain that is Ethereum. Each `Block` contains +a `Header` and zero or more transactions. Each `Header` contains associated +metadata like the block number, parent block hash, and how much gas was +consumed by its transactions. + +Together, these blocks form a cryptographically secure journal recording the +history of all state transitions that have happened since the genesis of the +chain. +""" + +from dataclasses import dataclass +from typing import Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes8, Bytes32 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.hash import Hash32 + +from .fork_types import Address, Bloom, Root +from .transactions import ( + AccessListTransaction, + BlobTransaction, + FeeMarketTransaction, + LegacyTransaction, + SetCodeTransaction, + Transaction, +) + + +@slotted_freezable +@dataclass +class Withdrawal: + """ + Withdrawals represent a transfer of ETH from the consensus layer (beacon + chain) to the execution layer, as validated by the consensus layer. Each + withdrawal is listed in the block's list of withdrawals. See [`block`]. + + [`block`]: ref:ethereum.forks.amsterdam.blocks.Block.withdrawals + """ + + index: U64 + """ + The unique index of the withdrawal, incremented for each withdrawal + processed. + """ + + validator_index: U64 + """ + The index of the validator on the consensus layer that is withdrawing. + """ + + address: Address + """ + The execution-layer address receiving the withdrawn ETH. + """ + + amount: U256 + """ + The amount of ETH being withdrawn. + """ + + +@slotted_freezable +@dataclass +class Header: + """ + Header portion of a block on the chain, containing metadata and + cryptographic commitments to the block's contents. + """ + + parent_hash: Hash32 + """ + Hash ([`keccak256`]) of the parent block's header, encoded with [RLP]. + + [`keccak256`]: ref:ethereum.crypto.hash.keccak256 + [RLP]: https://ethereum.github.io/ethereum-rlp/src/ethereum_rlp/rlp.py.html + """ + + ommers_hash: Hash32 + """ + Hash ([`keccak256`]) of the ommers (uncle blocks) in this block, encoded + with [RLP]. However, in post merge forks `ommers_hash` is always + [`EMPTY_OMMER_HASH`]. + + [`keccak256`]: ref:ethereum.crypto.hash.keccak256 + [RLP]: https://ethereum.github.io/ethereum-rlp/src/ethereum_rlp/rlp.py.html + [`EMPTY_OMMER_HASH`]: ref:ethereum.forks.amsterdam.fork.EMPTY_OMMER_HASH + """ + + coinbase: Address + """ + Address of the miner (or validator) who mined this block. + + The coinbase address receives the block reward and the priority fees (tips) + from included transactions. Base fees (introduced in [EIP-1559]) are burned + and do not go to the coinbase. + + [EIP-1559]: https://eips.ethereum.org/EIPS/eip-1559 + """ + + state_root: Root + """ + Root hash ([`keccak256`]) of the state trie after executing all + transactions in this block. It represents the state of the Ethereum Virtual + Machine (EVM) after all transactions in this block have been processed. It + is computed using the [`state_root()`] function, which computes the root + of the Merkle-Patricia [Trie] representing the Ethereum world state. + + [`keccak256`]: ref:ethereum.crypto.hash.keccak256 + [`state_root()`]: ref:ethereum.forks.amsterdam.state.state_root + [Trie]: ref:ethereum.forks.amsterdam.trie.Trie + """ + + transactions_root: Root + """ + Root hash ([`keccak256`]) of the transactions trie, which contains all + transactions included in this block in their original order. It is computed + using the [`root()`] function over the Merkle-Patricia [trie] of + transactions as the parameter. + + [`keccak256`]: ref:ethereum.crypto.hash.keccak256 + [`root()`]: ref:ethereum.forks.amsterdam.trie.root + [Trie]: ref:ethereum.forks.amsterdam.trie.Trie + """ + + receipt_root: Root + """ + Root hash ([`keccak256`]) of the receipts trie, which contains all receipts + for transactions in this block. It is computed using the [`root()`] + function over the Merkle-Patricia [trie] constructed from the receipts. + + [`keccak256`]: ref:ethereum.crypto.hash.keccak256 + [`root()`]: ref:ethereum.forks.amsterdam.trie.root + [Trie]: ref:ethereum.forks.amsterdam.trie.Trie + """ + + bloom: Bloom + """ + Bloom filter for logs generated by transactions in this block. + Constructed from all logs in the block using the [logs bloom] mechanism. + + [logs bloom]: ref:ethereum.forks.amsterdam.bloom.logs_bloom + """ + + difficulty: Uint + """ + Difficulty of the block (pre-PoS), or a constant in PoS. + """ + + number: Uint + """ + Block number, (height) in the chain. + """ + + gas_limit: Uint + """ + Maximum gas allowed in this block. Pre [EIP-1559], this was the maximum + gas that could be consumed by all transactions in the block. Post + [EIP-1559], this is still the maximum gas limit, but the base fee per gas + is also considered when calculating the effective gas limit. This can be + [adjusted by a factor of 1/1024] from the previous block's gas limit, up + until a maximum of 30 million gas. + + [EIP-1559]: https://eips.ethereum.org/EIPS/eip-1559 + [adjusted by a factor of 1/1024]: + https://ethereum.org/en/developers/docs/blocks/ + """ + + gas_used: Uint + """ + Total gas used by all transactions in this block. + """ + + timestamp: U256 + """ + Timestamp of when the block was mined, in seconds since the unix epoch. + """ + + extra_data: Bytes + """ + Arbitrary data included by the miner. + """ + + prev_randao: Bytes32 + """ + Output of the RANDAO beacon for random validator selection. + """ + + nonce: Bytes8 + """ + Nonce used in the mining process (pre-PoS), set to zero in PoS. + """ + + base_fee_per_gas: Uint + """ + Base fee per gas for transactions in this block, introduced in + [EIP-1559]. This is the minimum fee per gas that must be paid for a + transaction to be included in this block. + + [EIP-1559]: https://eips.ethereum.org/EIPS/eip-1559 + """ + + withdrawals_root: Root + """ + Root hash of the withdrawals trie, which contains all withdrawals in this + block. + """ + + blob_gas_used: U64 + """ + Total blob gas consumed by the transactions within this block. Introduced + in [EIP-4844]. + + [EIP-4844]: https://eips.ethereum.org/EIPS/eip-4844 + """ + + excess_blob_gas: U64 + """ + Running total of blob gas consumed in excess of the target, prior to this + block. Blocks with above-target blob gas consumption increase this value, + while blocks with below-target blob gas consumption decrease it (to a + minimum of zero). Introduced in [EIP-4844]. + + [EIP-4844]: https://eips.ethereum.org/EIPS/eip-4844 + """ + + parent_beacon_block_root: Root + """ + Root hash of the corresponding beacon chain block. + """ + + requests_hash: Hash32 + """ + [SHA2-256] hash of all the collected requests in this block. Introduced in + [EIP-7685]. See [`compute_requests_hash`][crh] for more details. + + [EIP-7685]: https://eips.ethereum.org/EIPS/eip-7685 + [crh]: ref:ethereum.forks.amsterdam.requests.compute_requests_hash + [SHA2-256]: https://en.wikipedia.org/wiki/SHA-2 + """ + + +@slotted_freezable +@dataclass +class Block: + """ + A complete block on Ethereum, which is composed of a block [`header`], + a list of transactions, a list of ommers (deprecated), and a list of + validator [withdrawals]. + + The block [`header`] includes fields relevant to the Proof-of-Stake + consensus, with deprecated Proof-of-Work fields such as `difficulty`, + `nonce`, and `ommersHash` set to constants. The `coinbase` field + denotes the address receiving priority fees from the block. + + The header also contains commitments to the current state (`stateRoot`), + the transactions (`transactionsRoot`), the transaction receipts + (`receiptsRoot`), and `withdrawalsRoot` committing to the validator + withdrawals included in this block. It also includes a bloom filter which + summarizes log data from the transactions. + + Withdrawals represent ETH transfers from validators to their recipients, + introduced by the consensus layer. Ommers remain deprecated and empty. + + [`header`]: ref:ethereum.forks.amsterdam.blocks.Header + [withdrawals]: ref:ethereum.forks.amsterdam.blocks.Withdrawal + """ + + header: Header + """ + The block header containing metadata and cryptographic commitments. Refer + [headers] for more details on the fields included in the header. + + [headers]: ref:ethereum.forks.amsterdam.blocks.Header + """ + + transactions: Tuple[Bytes | LegacyTransaction, ...] + """ + A tuple of transactions included in this block. Each transaction can be + any of a legacy transaction, an access list transaction, a fee market + transaction, a blob transaction, or a set code transaction. + """ + + ommers: Tuple[Header, ...] + """ + A tuple of ommers (uncle blocks) included in this block. Always empty in + Proof-of-Stake forks. + """ + + withdrawals: Tuple[Withdrawal, ...] + """ + A tuple of withdrawals processed in this block. + """ + + +@slotted_freezable +@dataclass +class Log: + """ + Data record produced during the execution of a transaction. Logs are used + by smart contracts to emit events (using the EVM log opcodes ([`LOG0`], + [`LOG1`], [`LOG2`], [`LOG3`] and [`LOG4`]), which can be efficiently + searched using the bloom filter in the block header. + + [`LOG0`]: ref:ethereum.forks.amsterdam.vm.instructions.log.log0 + [`LOG1`]: ref:ethereum.forks.amsterdam.vm.instructions.log.log1 + [`LOG2`]: ref:ethereum.forks.amsterdam.vm.instructions.log.log2 + [`LOG3`]: ref:ethereum.forks.amsterdam.vm.instructions.log.log3 + [`LOG4`]: ref:ethereum.forks.amsterdam.vm.instructions.log.log4 + """ + + address: Address + """ + The address of the contract that emitted the log. + """ + + topics: Tuple[Hash32, ...] + """ + A tuple of up to four topics associated with the log, used for filtering. + """ + + data: Bytes + """ + The data payload of the log, which can contain any arbitrary data. + """ + + +@slotted_freezable +@dataclass +class Receipt: + """ + Result of a transaction execution. Receipts are included in the receipts + trie. + """ + + succeeded: bool + """ + Whether the transaction execution was successful. + """ + + cumulative_gas_used: Uint + """ + Total gas used in the block up to and including this transaction. + """ + + bloom: Bloom + """ + Bloom filter for logs generated by this transaction. This is a 2048-byte + bit array that allows for efficient filtering of logs. + """ + + logs: Tuple[Log, ...] + """ + A tuple of logs generated by this transaction. Each log contains the + address of the contract that emitted it, a tuple of topics, and the data + payload. + """ + + +def encode_receipt(tx: Transaction, receipt: Receipt) -> Bytes | Receipt: + r""" + Encodes a transaction receipt based on the transaction type. + + The encoding follows the same format as transactions encoding, where: + - AccessListTransaction receipts are prefixed with `b"\x01"`. + - FeeMarketTransaction receipts are prefixed with `b"\x02"`. + - BlobTransaction receipts are prefixed with `b"\x03"`. + - SetCodeTransaction receipts are prefixed with `b"\x04"`. + - LegacyTransaction receipts are returned as is. + """ + if isinstance(tx, AccessListTransaction): + return b"\x01" + rlp.encode(receipt) + elif isinstance(tx, FeeMarketTransaction): + return b"\x02" + rlp.encode(receipt) + elif isinstance(tx, BlobTransaction): + return b"\x03" + rlp.encode(receipt) + elif isinstance(tx, SetCodeTransaction): + return b"\x04" + rlp.encode(receipt) + else: + return receipt + + +def decode_receipt(receipt: Bytes | Receipt) -> Receipt: + r""" + Decodes a receipt from its serialized form. + + The decoding follows the same format as transactions decoding, where: + - Receipts prefixed with `b"\x01"` are decoded as AccessListTransaction + receipts. + - Receipts prefixed with `b"\x02"` are decoded as FeeMarketTransaction + receipts. + - Receipts prefixed with `b"\x03"` are decoded as BlobTransaction + receipts. + - Receipts prefixed with `b"\x04"` are decoded as SetCodeTransaction + receipts. + - LegacyTransaction receipts are returned as is. + """ + if isinstance(receipt, Bytes): + assert receipt[0] in (1, 2, 3, 4) + return rlp.decode_to(Receipt, receipt[1:]) + else: + return receipt diff --git a/src/ethereum/forks/amsterdam/bloom.py b/src/ethereum/forks/amsterdam/bloom.py new file mode 100644 index 0000000000..8a12ec081d --- /dev/null +++ b/src/ethereum/forks/amsterdam/bloom.py @@ -0,0 +1,87 @@ +""" +Ethereum Logs Bloom. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +This modules defines functions for calculating bloom filters of logs. For the +general theory of bloom filters see e.g. `Wikipedia +`_. Bloom filters are used to allow +for efficient searching of logs by address and/or topic, by rapidly +eliminating blocks and receipts from their search. +""" + +from typing import Tuple + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import Uint + +from ethereum.crypto.hash import keccak256 + +from .blocks import Log +from .fork_types import Bloom + + +def add_to_bloom(bloom: bytearray, bloom_entry: Bytes) -> None: + """ + Add a bloom entry to the bloom filter (`bloom`). + + The number of hash functions used is 3. They are calculated by taking the + least significant 11 bits from the first 3 16-bit words of the + `keccak_256()` hash of `bloom_entry`. + + Parameters + ---------- + bloom : + The bloom filter. + bloom_entry : + An entry which is to be added to bloom filter. + + """ + hashed = keccak256(bloom_entry) + + for idx in (0, 2, 4): + # Obtain the least significant 11 bits from the pair of bytes + # (16 bits), and set this bit in bloom bytearray. + # The obtained bit is 0-indexed in the bloom filter from the least + # significant bit to the most significant bit. + bit_to_set = Uint.from_be_bytes(hashed[idx : idx + 2]) & Uint(0x07FF) + # Below is the index of the bit in the bytearray (where 0-indexed + # byte is the most significant byte) + bit_index = 0x07FF - int(bit_to_set) + + byte_index = bit_index // 8 + bit_value = 1 << (7 - (bit_index % 8)) + bloom[byte_index] = bloom[byte_index] | bit_value + + +def logs_bloom(logs: Tuple[Log, ...]) -> Bloom: + """ + Obtain the logs bloom from a list of log entries. + + The address and each topic of a log are added to the bloom filter. + + Parameters + ---------- + logs : + List of logs for which the logs bloom is to be obtained. + + Returns + ------- + logs_bloom : `Bloom` + The logs bloom obtained which is 256 bytes with some bits set as per + the caller address and the log topics. + + """ + bloom: bytearray = bytearray(b"\x00" * 256) + + for log in logs: + add_to_bloom(bloom, log.address) + for topic in log.topics: + add_to_bloom(bloom, topic) + + return Bloom(bloom) diff --git a/src/ethereum/forks/amsterdam/exceptions.py b/src/ethereum/forks/amsterdam/exceptions.py new file mode 100644 index 0000000000..3074a1f738 --- /dev/null +++ b/src/ethereum/forks/amsterdam/exceptions.py @@ -0,0 +1,131 @@ +""" +Exceptions specific to this fork. +""" + +from typing import TYPE_CHECKING, Final + +from ethereum_types.numeric import Uint + +from ethereum.exceptions import InvalidTransaction + +if TYPE_CHECKING: + from .transactions import Transaction + + +class TransactionTypeError(InvalidTransaction): + """ + Unknown [EIP-2718] transaction type byte. + + [EIP-2718]: https://eips.ethereum.org/EIPS/eip-2718 + """ + + transaction_type: Final[int] + """ + The type byte of the transaction that caused the error. + """ + + def __init__(self, transaction_type: int): + super().__init__(f"unknown transaction type `{transaction_type}`") + self.transaction_type = transaction_type + + +class TransactionTypeContractCreationError(InvalidTransaction): + """ + Contract creation is not allowed for a transaction type. + """ + + transaction: "Transaction" + """ + The transaction that caused the error. + """ + + def __init__(self, transaction: "Transaction"): + super().__init__( + f"transaction type `{type(transaction).__name__}` not allowed to " + "create contracts" + ) + self.transaction = transaction + + +class BlobGasLimitExceededError(InvalidTransaction): + """ + The blob gas limit for the transaction exceeds the maximum allowed. + """ + + +class InsufficientMaxFeePerBlobGasError(InvalidTransaction): + """ + The maximum fee per blob gas is insufficient for the transaction. + """ + + +class InsufficientMaxFeePerGasError(InvalidTransaction): + """ + The maximum fee per gas is insufficient for the transaction. + """ + + transaction_max_fee_per_gas: Final[Uint] + """ + The maximum fee per gas specified in the transaction. + """ + + block_base_fee_per_gas: Final[Uint] + """ + The base fee per gas of the block in which the transaction is included. + """ + + def __init__( + self, transaction_max_fee_per_gas: Uint, block_base_fee_per_gas: Uint + ): + super().__init__( + f"Insufficient max fee per gas " + f"({transaction_max_fee_per_gas} < {block_base_fee_per_gas})" + ) + self.transaction_max_fee_per_gas = transaction_max_fee_per_gas + self.block_base_fee_per_gas = block_base_fee_per_gas + + +class InvalidBlobVersionedHashError(InvalidTransaction): + """ + The versioned hash of the blob is invalid. + """ + + +class NoBlobDataError(InvalidTransaction): + """ + The transaction does not contain any blob data. + """ + + +class BlobCountExceededError(InvalidTransaction): + """ + The transaction has more blobs than the limit. + """ + + +class PriorityFeeGreaterThanMaxFeeError(InvalidTransaction): + """ + The priority fee is greater than the maximum fee per gas. + """ + + +class EmptyAuthorizationListError(InvalidTransaction): + """ + The authorization list in the transaction is empty. + """ + + +class InitCodeTooLargeError(InvalidTransaction): + """ + The init code of the transaction is too large. + """ + + +class TransactionGasLimitExceededError(InvalidTransaction): + """ + The transaction has specified a gas limit that is greater than the allowed + maximum. + + Note that this is _not_ the exception thrown when bytecode execution runs + out of gas. + """ diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py new file mode 100644 index 0000000000..1d8bbcc106 --- /dev/null +++ b/src/ethereum/forks/amsterdam/fork.py @@ -0,0 +1,1061 @@ +""" +Ethereum Specification. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Entry point for the Ethereum specification. +""" + +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.exceptions import ( + EthereumException, + GasUsedExceedsLimitError, + InsufficientBalanceError, + InvalidBlock, + InvalidSenderError, + NonceMismatchError, +) + +from . import vm +from .blocks import Block, Header, Log, Receipt, Withdrawal, encode_receipt +from .bloom import logs_bloom +from .exceptions import ( + BlobCountExceededError, + BlobGasLimitExceededError, + EmptyAuthorizationListError, + InsufficientMaxFeePerBlobGasError, + InsufficientMaxFeePerGasError, + InvalidBlobVersionedHashError, + NoBlobDataError, + PriorityFeeGreaterThanMaxFeeError, + TransactionTypeContractCreationError, +) +from .fork_types import Account, Address, Authorization, VersionedHash +from .requests import ( + CONSOLIDATION_REQUEST_TYPE, + DEPOSIT_REQUEST_TYPE, + WITHDRAWAL_REQUEST_TYPE, + compute_requests_hash, + parse_deposit_requests, +) +from .state import ( + State, + TransientStorage, + destroy_account, + get_account, + increment_nonce, + modify_state, + set_account_balance, + state_root, +) +from .transactions import ( + AccessListTransaction, + BlobTransaction, + FeeMarketTransaction, + LegacyTransaction, + SetCodeTransaction, + Transaction, + decode_transaction, + encode_transaction, + get_transaction_hash, + recover_sender, + validate_transaction, +) +from .trie import root, trie_set +from .utils.hexadecimal import hex_to_address +from .utils.message import prepare_message +from .vm import Message +from .vm.eoa_delegation import is_valid_delegation +from .vm.gas import ( + BLOB_SCHEDULE_MAX, + GAS_PER_BLOB, + calculate_blob_gas_price, + calculate_data_fee, + calculate_excess_blob_gas, + calculate_total_blob_gas, +) +from .vm.interpreter import MessageCallOutput, process_message_call + +BASE_FEE_MAX_CHANGE_DENOMINATOR = Uint(8) +ELASTICITY_MULTIPLIER = Uint(2) +GAS_LIMIT_ADJUSTMENT_FACTOR = Uint(1024) +GAS_LIMIT_MINIMUM = Uint(5000) +EMPTY_OMMER_HASH = keccak256(rlp.encode([])) +SYSTEM_ADDRESS = hex_to_address("0xfffffffffffffffffffffffffffffffffffffffe") +BEACON_ROOTS_ADDRESS = hex_to_address( + "0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02" +) +SYSTEM_TRANSACTION_GAS = Uint(30000000) +MAX_BLOB_GAS_PER_BLOCK = BLOB_SCHEDULE_MAX * GAS_PER_BLOB +VERSIONED_HASH_VERSION_KZG = b"\x01" + +WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS = hex_to_address( + "0x00000961Ef480Eb55e80D19ad83579A64c007002" +) +CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS = hex_to_address( + "0x0000BBdDc7CE488642fb579F8B00f3a590007251" +) +HISTORY_STORAGE_ADDRESS = hex_to_address( + "0x0000F90827F1C53a10cb7A02335B175320002935" +) +MAX_BLOCK_SIZE = 10_485_760 +SAFETY_MARGIN = 2_097_152 +MAX_RLP_BLOCK_SIZE = MAX_BLOCK_SIZE - SAFETY_MARGIN +BLOB_COUNT_LIMIT = 6 + + +@dataclass +class BlockChain: + """ + History and current state of the block chain. + """ + + blocks: List[Block] + state: State + chain_id: U64 + + +def apply_fork(old: BlockChain) -> BlockChain: + """ + Transforms the state from the previous hard fork (`old`) into the block + chain object for this hard fork and returns it. + + When forks need to implement an irregular state transition, this function + is used to handle the irregularity. See the :ref:`DAO Fork ` for + an example. + + Parameters + ---------- + old : + Previous block chain object. + + Returns + ------- + new : `BlockChain` + Upgraded block chain object for this hard fork. + + """ + return old + + +def get_last_256_block_hashes(chain: BlockChain) -> List[Hash32]: + """ + Obtain the list of hashes of the previous 256 blocks in order of + increasing block number. + + This function will return less hashes for the first 256 blocks. + + The ``BLOCKHASH`` opcode needs to access the latest hashes on the chain, + therefore this function retrieves them. + + Parameters + ---------- + chain : + History and current state. + + Returns + ------- + recent_block_hashes : `List[Hash32]` + Hashes of the recent 256 blocks in order of increasing block number. + + """ + recent_blocks = chain.blocks[-255:] + # TODO: This function has not been tested rigorously + if len(recent_blocks) == 0: + return [] + + recent_block_hashes = [] + + for block in recent_blocks: + prev_block_hash = block.header.parent_hash + recent_block_hashes.append(prev_block_hash) + + # We are computing the hash only for the most recent block and not for + # the rest of the blocks as they have successors which have the hash of + # the current block as parent hash. + most_recent_block_hash = keccak256(rlp.encode(recent_blocks[-1].header)) + recent_block_hashes.append(most_recent_block_hash) + + return recent_block_hashes + + +def state_transition(chain: BlockChain, block: Block) -> None: + """ + Attempts to apply a block to an existing block chain. + + All parts of the block's contents need to be verified before being added + to the chain. Blocks are verified by ensuring that the contents of the + block make logical sense with the contents of the parent block. The + information in the block's header must also match the corresponding + information in the block. + + To implement Ethereum, in theory clients are only required to store the + most recent 255 blocks of the chain since as far as execution is + concerned, only those blocks are accessed. Practically, however, clients + should store more blocks to handle reorgs. + + Parameters + ---------- + chain : + History and current state. + block : + Block to apply to `chain`. + + """ + if len(rlp.encode(block)) > MAX_RLP_BLOCK_SIZE: + raise InvalidBlock("Block rlp size exceeds MAX_RLP_BLOCK_SIZE") + + validate_header(chain, block.header) + if block.ommers != (): + raise InvalidBlock + + block_env = vm.BlockEnvironment( + chain_id=chain.chain_id, + state=chain.state, + block_gas_limit=block.header.gas_limit, + block_hashes=get_last_256_block_hashes(chain), + coinbase=block.header.coinbase, + number=block.header.number, + base_fee_per_gas=block.header.base_fee_per_gas, + time=block.header.timestamp, + prev_randao=block.header.prev_randao, + excess_blob_gas=block.header.excess_blob_gas, + parent_beacon_block_root=block.header.parent_beacon_block_root, + ) + + block_output = apply_body( + block_env=block_env, + transactions=block.transactions, + withdrawals=block.withdrawals, + ) + block_state_root = state_root(block_env.state) + transactions_root = root(block_output.transactions_trie) + receipt_root = root(block_output.receipts_trie) + block_logs_bloom = logs_bloom(block_output.block_logs) + withdrawals_root = root(block_output.withdrawals_trie) + requests_hash = compute_requests_hash(block_output.requests) + + if block_output.block_gas_used != block.header.gas_used: + raise InvalidBlock( + f"{block_output.block_gas_used} != {block.header.gas_used}" + ) + if transactions_root != block.header.transactions_root: + raise InvalidBlock + if block_state_root != block.header.state_root: + raise InvalidBlock + if receipt_root != block.header.receipt_root: + raise InvalidBlock + if block_logs_bloom != block.header.bloom: + raise InvalidBlock + if withdrawals_root != block.header.withdrawals_root: + raise InvalidBlock + if block_output.blob_gas_used != block.header.blob_gas_used: + raise InvalidBlock + if requests_hash != block.header.requests_hash: + raise InvalidBlock + + chain.blocks.append(block) + if len(chain.blocks) > 255: + # Real clients have to store more blocks to deal with reorgs, but the + # protocol only requires the last 255 + chain.blocks = chain.blocks[-255:] + + +def calculate_base_fee_per_gas( + block_gas_limit: Uint, + parent_gas_limit: Uint, + parent_gas_used: Uint, + parent_base_fee_per_gas: Uint, +) -> Uint: + """ + Calculates the base fee per gas for the block. + + Parameters + ---------- + block_gas_limit : + Gas limit of the block for which the base fee is being calculated. + parent_gas_limit : + Gas limit of the parent block. + parent_gas_used : + Gas used in the parent block. + parent_base_fee_per_gas : + Base fee per gas of the parent block. + + Returns + ------- + base_fee_per_gas : `Uint` + Base fee per gas for the block. + + """ + parent_gas_target = parent_gas_limit // ELASTICITY_MULTIPLIER + if not check_gas_limit(block_gas_limit, parent_gas_limit): + raise InvalidBlock + + if parent_gas_used == parent_gas_target: + expected_base_fee_per_gas = parent_base_fee_per_gas + elif parent_gas_used > parent_gas_target: + gas_used_delta = parent_gas_used - parent_gas_target + + parent_fee_gas_delta = parent_base_fee_per_gas * gas_used_delta + target_fee_gas_delta = parent_fee_gas_delta // parent_gas_target + + base_fee_per_gas_delta = max( + target_fee_gas_delta // BASE_FEE_MAX_CHANGE_DENOMINATOR, + Uint(1), + ) + + expected_base_fee_per_gas = ( + parent_base_fee_per_gas + base_fee_per_gas_delta + ) + else: + gas_used_delta = parent_gas_target - parent_gas_used + + parent_fee_gas_delta = parent_base_fee_per_gas * gas_used_delta + target_fee_gas_delta = parent_fee_gas_delta // parent_gas_target + + base_fee_per_gas_delta = ( + target_fee_gas_delta // BASE_FEE_MAX_CHANGE_DENOMINATOR + ) + + expected_base_fee_per_gas = ( + parent_base_fee_per_gas - base_fee_per_gas_delta + ) + + return Uint(expected_base_fee_per_gas) + + +def validate_header(chain: BlockChain, header: Header) -> None: + """ + Verifies a block header. + + In order to consider a block's header valid, the logic for the + quantities in the header should match the logic for the block itself. + For example the header timestamp should be greater than the block's parent + timestamp because the block was created *after* the parent block. + Additionally, the block's number should be directly following the parent + block's number since it is the next block in the sequence. + + Parameters + ---------- + chain : + History and current state. + header : + Header to check for correctness. + + """ + if header.number < Uint(1): + raise InvalidBlock + + parent_header = chain.blocks[-1].header + + excess_blob_gas = calculate_excess_blob_gas(parent_header) + if header.excess_blob_gas != excess_blob_gas: + raise InvalidBlock + + if header.gas_used > header.gas_limit: + raise InvalidBlock + + expected_base_fee_per_gas = calculate_base_fee_per_gas( + header.gas_limit, + parent_header.gas_limit, + parent_header.gas_used, + parent_header.base_fee_per_gas, + ) + if expected_base_fee_per_gas != header.base_fee_per_gas: + raise InvalidBlock + if header.timestamp <= parent_header.timestamp: + raise InvalidBlock + if header.number != parent_header.number + Uint(1): + raise InvalidBlock + if len(header.extra_data) > 32: + raise InvalidBlock + if header.difficulty != 0: + raise InvalidBlock + if header.nonce != b"\x00\x00\x00\x00\x00\x00\x00\x00": + raise InvalidBlock + if header.ommers_hash != EMPTY_OMMER_HASH: + raise InvalidBlock + + block_parent_hash = keccak256(rlp.encode(parent_header)) + if header.parent_hash != block_parent_hash: + raise InvalidBlock + + +def check_transaction( + block_env: vm.BlockEnvironment, + block_output: vm.BlockOutput, + tx: Transaction, +) -> Tuple[Address, Uint, Tuple[VersionedHash, ...], U64]: + """ + Check if the transaction is includable in the block. + + Parameters + ---------- + block_env : + The block scoped environment. + block_output : + The block output for the current block. + tx : + The transaction. + + Returns + ------- + sender_address : + The sender of the transaction. + effective_gas_price : + The price to charge for gas when the transaction is executed. + blob_versioned_hashes : + The blob versioned hashes of the transaction. + tx_blob_gas_used: + The blob gas used by the transaction. + + Raises + ------ + InvalidBlock : + If the transaction is not includable. + GasUsedExceedsLimitError : + If the gas used by the transaction exceeds the block's gas limit. + NonceMismatchError : + If the nonce of the transaction is not equal to the sender's nonce. + InsufficientBalanceError : + If the sender's balance is not enough to pay for the transaction. + InvalidSenderError : + If the transaction is from an address that does not exist anymore. + PriorityFeeGreaterThanMaxFeeError : + If the priority fee is greater than the maximum fee per gas. + InsufficientMaxFeePerGasError : + If the maximum fee per gas is insufficient for the transaction. + InsufficientMaxFeePerBlobGasError : + If the maximum fee per blob gas is insufficient for the transaction. + BlobGasLimitExceededError : + If the blob gas used by the transaction exceeds the block's blob gas + limit. + InvalidBlobVersionedHashError : + If the transaction contains a blob versioned hash with an invalid + version. + NoBlobDataError : + If the transaction is a type 3 but has no blobs. + BlobCountExceededError : + If the transaction is a type 3 and has more blobs than the limit. + TransactionTypeContractCreationError: + If the transaction type is not allowed to create contracts. + EmptyAuthorizationListError : + If the transaction is a SetCodeTransaction and the authorization list + is empty. + + """ + gas_available = block_env.block_gas_limit - block_output.block_gas_used + blob_gas_available = MAX_BLOB_GAS_PER_BLOCK - block_output.blob_gas_used + + if tx.gas > gas_available: + raise GasUsedExceedsLimitError("gas used exceeds limit") + + tx_blob_gas_used = calculate_total_blob_gas(tx) + if tx_blob_gas_used > blob_gas_available: + raise BlobGasLimitExceededError("blob gas limit exceeded") + + sender_address = recover_sender(block_env.chain_id, tx) + sender_account = get_account(block_env.state, sender_address) + + if isinstance( + tx, (FeeMarketTransaction, BlobTransaction, SetCodeTransaction) + ): + if tx.max_fee_per_gas < tx.max_priority_fee_per_gas: + raise PriorityFeeGreaterThanMaxFeeError( + "priority fee greater than max fee" + ) + if tx.max_fee_per_gas < block_env.base_fee_per_gas: + raise InsufficientMaxFeePerGasError( + tx.max_fee_per_gas, block_env.base_fee_per_gas + ) + + priority_fee_per_gas = min( + tx.max_priority_fee_per_gas, + tx.max_fee_per_gas - block_env.base_fee_per_gas, + ) + effective_gas_price = priority_fee_per_gas + block_env.base_fee_per_gas + max_gas_fee = tx.gas * tx.max_fee_per_gas + else: + if tx.gas_price < block_env.base_fee_per_gas: + raise InvalidBlock + effective_gas_price = tx.gas_price + max_gas_fee = tx.gas * tx.gas_price + + if isinstance(tx, BlobTransaction): + blob_count = len(tx.blob_versioned_hashes) + if blob_count == 0: + raise NoBlobDataError("no blob data in transaction") + if blob_count > BLOB_COUNT_LIMIT: + raise BlobCountExceededError( + f"Tx has {blob_count} blobs. Max allowed: {BLOB_COUNT_LIMIT}" + ) + for blob_versioned_hash in tx.blob_versioned_hashes: + if blob_versioned_hash[0:1] != VERSIONED_HASH_VERSION_KZG: + raise InvalidBlobVersionedHashError( + "invalid blob versioned hash" + ) + + blob_gas_price = calculate_blob_gas_price(block_env.excess_blob_gas) + if Uint(tx.max_fee_per_blob_gas) < blob_gas_price: + raise InsufficientMaxFeePerBlobGasError( + "insufficient max fee per blob gas" + ) + + max_gas_fee += Uint(calculate_total_blob_gas(tx)) * Uint( + tx.max_fee_per_blob_gas + ) + blob_versioned_hashes = tx.blob_versioned_hashes + else: + blob_versioned_hashes = () + + if isinstance(tx, (BlobTransaction, SetCodeTransaction)): + if not isinstance(tx.to, Address): + raise TransactionTypeContractCreationError(tx) + + if isinstance(tx, SetCodeTransaction): + if not any(tx.authorizations): + raise EmptyAuthorizationListError("empty authorization list") + + if sender_account.nonce > Uint(tx.nonce): + raise NonceMismatchError("nonce too low") + elif sender_account.nonce < Uint(tx.nonce): + raise NonceMismatchError("nonce too high") + + if Uint(sender_account.balance) < max_gas_fee + Uint(tx.value): + raise InsufficientBalanceError("insufficient sender balance") + if sender_account.code and not is_valid_delegation(sender_account.code): + raise InvalidSenderError("not EOA") + + return ( + sender_address, + effective_gas_price, + blob_versioned_hashes, + tx_blob_gas_used, + ) + + +def make_receipt( + tx: Transaction, + error: Optional[EthereumException], + cumulative_gas_used: Uint, + logs: Tuple[Log, ...], +) -> Bytes | Receipt: + """ + Make the receipt for a transaction that was executed. + + Parameters + ---------- + tx : + The executed transaction. + error : + Error in the top level frame of the transaction, if any. + cumulative_gas_used : + The total gas used so far in the block after the transaction was + executed. + logs : + The logs produced by the transaction. + + Returns + ------- + receipt : + The receipt for the transaction. + + """ + receipt = Receipt( + succeeded=error is None, + cumulative_gas_used=cumulative_gas_used, + bloom=logs_bloom(logs), + logs=logs, + ) + + return encode_receipt(tx, receipt) + + +def process_system_transaction( + block_env: vm.BlockEnvironment, + target_address: Address, + system_contract_code: Bytes, + data: Bytes, +) -> MessageCallOutput: + """ + Process a system transaction with the given code. + + Prefer calling `process_checked_system_transaction` or + `process_unchecked_system_transaction` depending on whether missing code or + an execution error should cause the block to be rejected. + + Parameters + ---------- + block_env : + The block scoped environment. + target_address : + Address of the contract to call. + system_contract_code : + Code of the contract to call. + data : + Data to pass to the contract. + + Returns + ------- + system_tx_output : `MessageCallOutput` + Output of processing the system transaction. + + """ + tx_env = vm.TransactionEnvironment( + origin=SYSTEM_ADDRESS, + gas_price=block_env.base_fee_per_gas, + gas=SYSTEM_TRANSACTION_GAS, + access_list_addresses=set(), + access_list_storage_keys=set(), + transient_storage=TransientStorage(), + blob_versioned_hashes=(), + authorizations=(), + index_in_block=None, + tx_hash=None, + ) + + system_tx_message = Message( + block_env=block_env, + tx_env=tx_env, + caller=SYSTEM_ADDRESS, + target=target_address, + gas=SYSTEM_TRANSACTION_GAS, + value=U256(0), + data=data, + code=system_contract_code, + depth=Uint(0), + current_target=target_address, + code_address=target_address, + should_transfer_value=False, + is_static=False, + accessed_addresses=set(), + accessed_storage_keys=set(), + disable_precompiles=False, + parent_evm=None, + ) + + system_tx_output = process_message_call(system_tx_message) + + return system_tx_output + + +def process_checked_system_transaction( + block_env: vm.BlockEnvironment, + target_address: Address, + data: Bytes, +) -> MessageCallOutput: + """ + Process a system transaction and raise an error if the contract does not + contain code or if the transaction fails. + + Parameters + ---------- + block_env : + The block scoped environment. + target_address : + Address of the contract to call. + data : + Data to pass to the contract. + + Returns + ------- + system_tx_output : `MessageCallOutput` + Output of processing the system transaction. + + """ + system_contract_code = get_account(block_env.state, target_address).code + + if len(system_contract_code) == 0: + raise InvalidBlock( + f"System contract address {target_address.hex()} does not " + "contain code" + ) + + system_tx_output = process_system_transaction( + block_env, + target_address, + system_contract_code, + data, + ) + + if system_tx_output.error: + raise InvalidBlock( + f"System contract ({target_address.hex()}) call failed: " + f"{system_tx_output.error}" + ) + + return system_tx_output + + +def process_unchecked_system_transaction( + block_env: vm.BlockEnvironment, + target_address: Address, + data: Bytes, +) -> MessageCallOutput: + """ + Process a system transaction without checking if the contract contains code + or if the transaction fails. + + Parameters + ---------- + block_env : + The block scoped environment. + target_address : + Address of the contract to call. + data : + Data to pass to the contract. + + Returns + ------- + system_tx_output : `MessageCallOutput` + Output of processing the system transaction. + + """ + system_contract_code = get_account(block_env.state, target_address).code + return process_system_transaction( + block_env, + target_address, + system_contract_code, + data, + ) + + +def apply_body( + block_env: vm.BlockEnvironment, + transactions: Tuple[LegacyTransaction | Bytes, ...], + withdrawals: Tuple[Withdrawal, ...], +) -> vm.BlockOutput: + """ + Executes a block. + + Many of the contents of a block are stored in data structures called + tries. There is a transactions trie which is similar to a ledger of the + transactions stored in the current block. There is also a receipts trie + which stores the results of executing a transaction, like the post state + and gas used. This function creates and executes the block that is to be + added to the chain. + + Parameters + ---------- + block_env : + The block scoped environment. + transactions : + Transactions included in the block. + withdrawals : + Withdrawals to be processed in the current block. + + Returns + ------- + block_output : + The block output for the current block. + + """ + block_output = vm.BlockOutput() + + process_unchecked_system_transaction( + block_env=block_env, + target_address=BEACON_ROOTS_ADDRESS, + data=block_env.parent_beacon_block_root, + ) + + process_unchecked_system_transaction( + block_env=block_env, + target_address=HISTORY_STORAGE_ADDRESS, + data=block_env.block_hashes[-1], # The parent hash + ) + + for i, tx in enumerate(map(decode_transaction, transactions)): + process_transaction(block_env, block_output, tx, Uint(i)) + + process_withdrawals(block_env, block_output, withdrawals) + + process_general_purpose_requests( + block_env=block_env, + block_output=block_output, + ) + + return block_output + + +def process_general_purpose_requests( + block_env: vm.BlockEnvironment, + block_output: vm.BlockOutput, +) -> None: + """ + Process all the requests in the block. + + Parameters + ---------- + block_env : + The execution environment for the Block. + block_output : + The block output for the current block. + + """ + # Requests are to be in ascending order of request type + deposit_requests = parse_deposit_requests(block_output) + requests_from_execution = block_output.requests + if len(deposit_requests) > 0: + requests_from_execution.append(DEPOSIT_REQUEST_TYPE + deposit_requests) + + system_withdrawal_tx_output = process_checked_system_transaction( + block_env=block_env, + target_address=WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + data=b"", + ) + + if len(system_withdrawal_tx_output.return_data) > 0: + requests_from_execution.append( + WITHDRAWAL_REQUEST_TYPE + system_withdrawal_tx_output.return_data + ) + + system_consolidation_tx_output = process_checked_system_transaction( + block_env=block_env, + target_address=CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, + data=b"", + ) + + if len(system_consolidation_tx_output.return_data) > 0: + requests_from_execution.append( + CONSOLIDATION_REQUEST_TYPE + + system_consolidation_tx_output.return_data + ) + + +def process_transaction( + block_env: vm.BlockEnvironment, + block_output: vm.BlockOutput, + tx: Transaction, + index: Uint, +) -> None: + """ + Execute a transaction against the provided environment. + + This function processes the actions needed to execute a transaction. + It decrements the sender's account balance after calculating the gas fee + and refunds them the proper amount after execution. Calling contracts, + deploying code, and incrementing nonces are all examples of actions that + happen within this function or from a call made within this function. + + Accounts that are marked for deletion are processed and destroyed after + execution. + + Parameters + ---------- + block_env : + Environment for the Ethereum Virtual Machine. + block_output : + The block output for the current block. + tx : + Transaction to execute. + index: + Index of the transaction in the block. + + """ + trie_set( + block_output.transactions_trie, + rlp.encode(index), + encode_transaction(tx), + ) + + intrinsic_gas, calldata_floor_gas_cost = validate_transaction(tx) + + ( + sender, + effective_gas_price, + blob_versioned_hashes, + tx_blob_gas_used, + ) = check_transaction( + block_env=block_env, + block_output=block_output, + tx=tx, + ) + + sender_account = get_account(block_env.state, sender) + + if isinstance(tx, BlobTransaction): + blob_gas_fee = calculate_data_fee(block_env.excess_blob_gas, tx) + else: + blob_gas_fee = Uint(0) + + effective_gas_fee = tx.gas * effective_gas_price + + gas = tx.gas - intrinsic_gas + increment_nonce(block_env.state, sender) + + sender_balance_after_gas_fee = ( + Uint(sender_account.balance) - effective_gas_fee - blob_gas_fee + ) + set_account_balance( + block_env.state, sender, U256(sender_balance_after_gas_fee) + ) + + access_list_addresses = set() + access_list_storage_keys = set() + access_list_addresses.add(block_env.coinbase) + if isinstance( + tx, + ( + AccessListTransaction, + FeeMarketTransaction, + BlobTransaction, + SetCodeTransaction, + ), + ): + for access in tx.access_list: + access_list_addresses.add(access.account) + for slot in access.slots: + access_list_storage_keys.add((access.account, slot)) + + authorizations: Tuple[Authorization, ...] = () + if isinstance(tx, SetCodeTransaction): + authorizations = tx.authorizations + + tx_env = vm.TransactionEnvironment( + origin=sender, + gas_price=effective_gas_price, + gas=gas, + access_list_addresses=access_list_addresses, + access_list_storage_keys=access_list_storage_keys, + transient_storage=TransientStorage(), + blob_versioned_hashes=blob_versioned_hashes, + authorizations=authorizations, + index_in_block=index, + tx_hash=get_transaction_hash(encode_transaction(tx)), + ) + + message = prepare_message(block_env, tx_env, tx) + + tx_output = process_message_call(message) + + # For EIP-7623 we first calculate the execution_gas_used, which includes + # the execution gas refund. + tx_gas_used_before_refund = tx.gas - tx_output.gas_left + tx_gas_refund = min( + tx_gas_used_before_refund // Uint(5), Uint(tx_output.refund_counter) + ) + tx_gas_used_after_refund = tx_gas_used_before_refund - tx_gas_refund + + # Transactions with less execution_gas_used than the floor pay at the + # floor cost. + tx_gas_used_after_refund = max( + tx_gas_used_after_refund, calldata_floor_gas_cost + ) + + tx_gas_left = tx.gas - tx_gas_used_after_refund + gas_refund_amount = tx_gas_left * effective_gas_price + + # For non-1559 transactions effective_gas_price == tx.gas_price + priority_fee_per_gas = effective_gas_price - block_env.base_fee_per_gas + transaction_fee = tx_gas_used_after_refund * priority_fee_per_gas + + # refund gas + sender_balance_after_refund = get_account( + block_env.state, sender + ).balance + U256(gas_refund_amount) + set_account_balance(block_env.state, sender, sender_balance_after_refund) + + # transfer miner fees + coinbase_balance_after_mining_fee = get_account( + block_env.state, block_env.coinbase + ).balance + U256(transaction_fee) + set_account_balance( + block_env.state, + block_env.coinbase, + coinbase_balance_after_mining_fee, + ) + + for address in tx_output.accounts_to_delete: + destroy_account(block_env.state, address) + + block_output.block_gas_used += tx_gas_used_after_refund + block_output.blob_gas_used += tx_blob_gas_used + + receipt = make_receipt( + tx, tx_output.error, block_output.block_gas_used, tx_output.logs + ) + + receipt_key = rlp.encode(Uint(index)) + block_output.receipt_keys += (receipt_key,) + + trie_set( + block_output.receipts_trie, + receipt_key, + receipt, + ) + + block_output.block_logs += tx_output.logs + + +def process_withdrawals( + block_env: vm.BlockEnvironment, + block_output: vm.BlockOutput, + withdrawals: Tuple[Withdrawal, ...], +) -> None: + """ + Increase the balance of the withdrawing account. + """ + + def increase_recipient_balance(recipient: Account) -> None: + recipient.balance += wd.amount * U256(10**9) + + for i, wd in enumerate(withdrawals): + trie_set( + block_output.withdrawals_trie, + rlp.encode(Uint(i)), + rlp.encode(wd), + ) + + modify_state(block_env.state, wd.address, increase_recipient_balance) + + +def check_gas_limit(gas_limit: Uint, parent_gas_limit: Uint) -> bool: + """ + Validates the gas limit for a block. + + The bounds of the gas limit, ``max_adjustment_delta``, is set as the + quotient of the parent block's gas limit and the + ``GAS_LIMIT_ADJUSTMENT_FACTOR``. Therefore, if the gas limit that is + passed through as a parameter is greater than or equal to the *sum* of + the parent's gas and the adjustment delta then the limit for gas is too + high and fails this function's check. Similarly, if the limit is less + than or equal to the *difference* of the parent's gas and the adjustment + delta *or* the predefined ``GAS_LIMIT_MINIMUM`` then this function's + check fails because the gas limit doesn't allow for a sufficient or + reasonable amount of gas to be used on a block. + + Parameters + ---------- + gas_limit : + Gas limit to validate. + + parent_gas_limit : + Gas limit of the parent block. + + Returns + ------- + check : `bool` + True if gas limit constraints are satisfied, False otherwise. + + """ + max_adjustment_delta = parent_gas_limit // GAS_LIMIT_ADJUSTMENT_FACTOR + if gas_limit >= parent_gas_limit + max_adjustment_delta: + return False + if gas_limit <= parent_gas_limit - max_adjustment_delta: + return False + if gas_limit < GAS_LIMIT_MINIMUM: + return False + + return True diff --git a/src/ethereum/forks/amsterdam/fork_types.py b/src/ethereum/forks/amsterdam/fork_types.py new file mode 100644 index 0000000000..24fbf08baf --- /dev/null +++ b/src/ethereum/forks/amsterdam/fork_types.py @@ -0,0 +1,78 @@ +""" +Ethereum Types. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Types reused throughout the specification, which are specific to Ethereum. +""" + +from dataclasses import dataclass + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes20, Bytes256 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U8, U64, U256, Uint + +from ethereum.crypto.hash import Hash32, keccak256 + +Address = Bytes20 +Root = Hash32 +VersionedHash = Hash32 + +Bloom = Bytes256 + + +@slotted_freezable +@dataclass +class Account: + """ + State associated with an address. + """ + + nonce: Uint + balance: U256 + code: Bytes + + +EMPTY_ACCOUNT = Account( + nonce=Uint(0), + balance=U256(0), + code=b"", +) + + +def encode_account(raw_account_data: Account, storage_root: Bytes) -> Bytes: + """ + Encode `Account` dataclass. + + Storage is not stored in the `Account` dataclass, so `Accounts` cannot be + encoded without providing a storage root. + """ + return rlp.encode( + ( + raw_account_data.nonce, + raw_account_data.balance, + storage_root, + keccak256(raw_account_data.code), + ) + ) + + +@slotted_freezable +@dataclass +class Authorization: + """ + The authorization for a set code transaction. + """ + + chain_id: U256 + address: Address + nonce: U64 + y_parity: U8 + r: U256 + s: U256 diff --git a/src/ethereum/forks/amsterdam/requests.py b/src/ethereum/forks/amsterdam/requests.py new file mode 100644 index 0000000000..929e973e58 --- /dev/null +++ b/src/ethereum/forks/amsterdam/requests.py @@ -0,0 +1,191 @@ +""" +Requests were introduced in EIP-7685 as a a general purpose framework for +storing contract-triggered requests. It extends the execution header and +body with a single field each to store the request information. +This inherently exposes the requests to the consensus layer, which can +then process each one. + +[EIP-7685]: https://eips.ethereum.org/EIPS/eip-7685 +""" + +from hashlib import sha256 +from typing import List + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import Uint, ulen + +from ethereum.exceptions import InvalidBlock +from ethereum.utils.hexadecimal import hex_to_bytes32 + +from .blocks import decode_receipt +from .trie import trie_get +from .utils.hexadecimal import hex_to_address +from .vm import BlockOutput + +DEPOSIT_CONTRACT_ADDRESS = hex_to_address( + "0x00000000219ab540356cbb839cbe05303d7705fa" +) +DEPOSIT_EVENT_SIGNATURE_HASH = hex_to_bytes32( + "0x649bbc62d0e31342afea4e5cd82d4049e7e1ee912fc0889aa790803be39038c5" +) +DEPOSIT_REQUEST_TYPE = b"\x00" +WITHDRAWAL_REQUEST_TYPE = b"\x01" +CONSOLIDATION_REQUEST_TYPE = b"\x02" + + +DEPOSIT_EVENT_LENGTH = Uint(576) + +PUBKEY_OFFSET = Uint(160) +WITHDRAWAL_CREDENTIALS_OFFSET = Uint(256) +AMOUNT_OFFSET = Uint(320) +SIGNATURE_OFFSET = Uint(384) +INDEX_OFFSET = Uint(512) + +PUBKEY_SIZE = Uint(48) +WITHDRAWAL_CREDENTIALS_SIZE = Uint(32) +AMOUNT_SIZE = Uint(8) +SIGNATURE_SIZE = Uint(96) +INDEX_SIZE = Uint(8) + + +def extract_deposit_data(data: Bytes) -> Bytes: + """ + Extracts Deposit Request from the DepositContract.DepositEvent data. + + Raises + ------ + InvalidBlock : + If the deposit contract did not produce a valid log. + + """ + if ulen(data) != DEPOSIT_EVENT_LENGTH: + raise InvalidBlock("Invalid deposit event data length") + + # Check that all the offsets are in order + pubkey_offset = Uint.from_be_bytes(data[0:32]) + if pubkey_offset != PUBKEY_OFFSET: + raise InvalidBlock("Invalid pubkey offset in deposit log") + + withdrawal_credentials_offset = Uint.from_be_bytes(data[32:64]) + if withdrawal_credentials_offset != WITHDRAWAL_CREDENTIALS_OFFSET: + raise InvalidBlock( + "Invalid withdrawal credentials offset in deposit log" + ) + + amount_offset = Uint.from_be_bytes(data[64:96]) + if amount_offset != AMOUNT_OFFSET: + raise InvalidBlock("Invalid amount offset in deposit log") + + signature_offset = Uint.from_be_bytes(data[96:128]) + if signature_offset != SIGNATURE_OFFSET: + raise InvalidBlock("Invalid signature offset in deposit log") + + index_offset = Uint.from_be_bytes(data[128:160]) + if index_offset != INDEX_OFFSET: + raise InvalidBlock("Invalid index offset in deposit log") + + # Check that all the sizes are in order + pubkey_size = Uint.from_be_bytes( + data[pubkey_offset : pubkey_offset + Uint(32)] + ) + if pubkey_size != PUBKEY_SIZE: + raise InvalidBlock("Invalid pubkey size in deposit log") + + pubkey = data[ + pubkey_offset + Uint(32) : pubkey_offset + Uint(32) + PUBKEY_SIZE + ] + + withdrawal_credentials_size = Uint.from_be_bytes( + data[ + withdrawal_credentials_offset : withdrawal_credentials_offset + + Uint(32) + ], + ) + if withdrawal_credentials_size != WITHDRAWAL_CREDENTIALS_SIZE: + raise InvalidBlock( + "Invalid withdrawal credentials size in deposit log" + ) + + withdrawal_credentials = data[ + withdrawal_credentials_offset + + Uint(32) : withdrawal_credentials_offset + + Uint(32) + + WITHDRAWAL_CREDENTIALS_SIZE + ] + + amount_size = Uint.from_be_bytes( + data[amount_offset : amount_offset + Uint(32)] + ) + if amount_size != AMOUNT_SIZE: + raise InvalidBlock("Invalid amount size in deposit log") + + amount = data[ + amount_offset + Uint(32) : amount_offset + Uint(32) + AMOUNT_SIZE + ] + + signature_size = Uint.from_be_bytes( + data[signature_offset : signature_offset + Uint(32)] + ) + if signature_size != SIGNATURE_SIZE: + raise InvalidBlock("Invalid signature size in deposit log") + + signature = data[ + signature_offset + Uint(32) : signature_offset + + Uint(32) + + SIGNATURE_SIZE + ] + + index_size = Uint.from_be_bytes( + data[index_offset : index_offset + Uint(32)] + ) + if index_size != INDEX_SIZE: + raise InvalidBlock("Invalid index size in deposit log") + + index = data[ + index_offset + Uint(32) : index_offset + Uint(32) + INDEX_SIZE + ] + + return pubkey + withdrawal_credentials + amount + signature + index + + +def parse_deposit_requests(block_output: BlockOutput) -> Bytes: + """ + Parse deposit requests from the block output. + """ + deposit_requests: Bytes = b"" + for key in block_output.receipt_keys: + receipt = trie_get(block_output.receipts_trie, key) + assert receipt is not None + decoded_receipt = decode_receipt(receipt) + for log in decoded_receipt.logs: + if log.address == DEPOSIT_CONTRACT_ADDRESS: + if ( + len(log.topics) > 0 + and log.topics[0] == DEPOSIT_EVENT_SIGNATURE_HASH + ): + request = extract_deposit_data(log.data) + deposit_requests += request + + return deposit_requests + + +def compute_requests_hash(requests: List[Bytes]) -> Bytes: + """ + Get the hash of the requests using the SHA2-256 algorithm. + + Parameters + ---------- + requests : Bytes + The requests to hash. + + Returns + ------- + requests_hash : Bytes + The hash of the requests. + + """ + m = sha256() + for request in requests: + m.update(sha256(request).digest()) + + return m.digest() diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py new file mode 100644 index 0000000000..e997411f6d --- /dev/null +++ b/src/ethereum/forks/amsterdam/state.py @@ -0,0 +1,667 @@ +""" +State. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +The state contains all information that is preserved between transactions. + +It consists of a main account trie and storage tries for each contract. + +There is a distinction between an account that does not exist and +`EMPTY_ACCOUNT`. +""" + +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Set, Tuple + +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.frozen import modify +from ethereum_types.numeric import U256, Uint + +from .fork_types import EMPTY_ACCOUNT, Account, Address, Root +from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set + + +@dataclass +class State: + """ + Contains all information that is preserved between transactions. + """ + + _main_trie: Trie[Address, Optional[Account]] = field( + default_factory=lambda: Trie(secured=True, default=None) + ) + _storage_tries: Dict[Address, Trie[Bytes32, U256]] = field( + default_factory=dict + ) + _snapshots: List[ + Tuple[ + Trie[Address, Optional[Account]], + Dict[Address, Trie[Bytes32, U256]], + ] + ] = field(default_factory=list) + created_accounts: Set[Address] = field(default_factory=set) + + +@dataclass +class TransientStorage: + """ + Contains all information that is preserved between message calls + within a transaction. + """ + + _tries: Dict[Address, Trie[Bytes32, U256]] = field(default_factory=dict) + _snapshots: List[Dict[Address, Trie[Bytes32, U256]]] = field( + default_factory=list + ) + + +def close_state(state: State) -> None: + """ + Free resources held by the state. Used by optimized implementations to + release file descriptors. + """ + del state._main_trie + del state._storage_tries + del state._snapshots + del state.created_accounts + + +def begin_transaction( + state: State, transient_storage: TransientStorage +) -> None: + """ + Start a state transaction. + + Transactions are entirely implicit and can be nested. It is not possible to + calculate the state root during a transaction. + + Parameters + ---------- + state : State + The state. + transient_storage : TransientStorage + The transient storage of the transaction. + + """ + state._snapshots.append( + ( + copy_trie(state._main_trie), + {k: copy_trie(t) for (k, t) in state._storage_tries.items()}, + ) + ) + transient_storage._snapshots.append( + {k: copy_trie(t) for (k, t) in transient_storage._tries.items()} + ) + + +def commit_transaction( + state: State, transient_storage: TransientStorage +) -> None: + """ + Commit a state transaction. + + Parameters + ---------- + state : State + The state. + transient_storage : TransientStorage + The transient storage of the transaction. + + """ + state._snapshots.pop() + if not state._snapshots: + state.created_accounts.clear() + + transient_storage._snapshots.pop() + + +def rollback_transaction( + state: State, transient_storage: TransientStorage +) -> None: + """ + Rollback a state transaction, resetting the state to the point when the + corresponding `begin_transaction()` call was made. + + Parameters + ---------- + state : State + The state. + transient_storage : TransientStorage + The transient storage of the transaction. + + """ + state._main_trie, state._storage_tries = state._snapshots.pop() + if not state._snapshots: + state.created_accounts.clear() + + transient_storage._tries = transient_storage._snapshots.pop() + + +def get_account(state: State, address: Address) -> Account: + """ + Get the `Account` object at an address. Returns `EMPTY_ACCOUNT` if there + is no account at the address. + + Use `get_account_optional()` if you care about the difference between a + non-existent account and `EMPTY_ACCOUNT`. + + Parameters + ---------- + state: `State` + The state + address : `Address` + Address to lookup. + + Returns + ------- + account : `Account` + Account at address. + + """ + account = get_account_optional(state, address) + if isinstance(account, Account): + return account + else: + return EMPTY_ACCOUNT + + +def get_account_optional(state: State, address: Address) -> Optional[Account]: + """ + Get the `Account` object at an address. Returns `None` (rather than + `EMPTY_ACCOUNT`) if there is no account at the address. + + Parameters + ---------- + state: `State` + The state + address : `Address` + Address to lookup. + + Returns + ------- + account : `Account` + Account at address. + + """ + account = trie_get(state._main_trie, address) + return account + + +def set_account( + state: State, address: Address, account: Optional[Account] +) -> None: + """ + Set the `Account` object at an address. Setting to `None` deletes + the account (but not its storage, see `destroy_account()`). + + Parameters + ---------- + state: `State` + The state + address : `Address` + Address to set. + account : `Account` + Account to set at address. + + """ + trie_set(state._main_trie, address, account) + + +def destroy_account(state: State, address: Address) -> None: + """ + Completely remove the account at `address` and all of its storage. + + This function is made available exclusively for the `SELFDESTRUCT` + opcode. It is expected that `SELFDESTRUCT` will be disabled in a future + hardfork and this function will be removed. + + Parameters + ---------- + state: `State` + The state + address : `Address` + Address of account to destroy. + + """ + destroy_storage(state, address) + set_account(state, address, None) + + +def destroy_storage(state: State, address: Address) -> None: + """ + Completely remove the storage at `address`. + + Parameters + ---------- + state: `State` + The state + address : `Address` + Address of account whose storage is to be deleted. + + """ + if address in state._storage_tries: + del state._storage_tries[address] + + +def mark_account_created(state: State, address: Address) -> None: + """ + Mark an account as having been created in the current transaction. + This information is used by `get_storage_original()` to handle an obscure + edgecase, and to respect the constraints added to SELFDESTRUCT by + EIP-6780. + + The marker is not removed even if the account creation reverts. Since the + account cannot have had code prior to its creation and can't call + `get_storage_original()`, this is harmless. + + Parameters + ---------- + state: `State` + The state + address : `Address` + Address of the account that has been created. + + """ + state.created_accounts.add(address) + + +def get_storage(state: State, address: Address, key: Bytes32) -> U256: + """ + Get a value at a storage key on an account. Returns `U256(0)` if the + storage key has not been set previously. + + Parameters + ---------- + state: `State` + The state + address : `Address` + Address of the account. + key : `Bytes` + Key to lookup. + + Returns + ------- + value : `U256` + Value at the key. + + """ + trie = state._storage_tries.get(address) + if trie is None: + return U256(0) + + value = trie_get(trie, key) + + assert isinstance(value, U256) + return value + + +def set_storage( + state: State, address: Address, key: Bytes32, value: U256 +) -> None: + """ + Set a value at a storage key on an account. Setting to `U256(0)` deletes + the key. + + Parameters + ---------- + state: `State` + The state + address : `Address` + Address of the account. + key : `Bytes` + Key to set. + value : `U256` + Value to set at the key. + + """ + assert trie_get(state._main_trie, address) is not None + + trie = state._storage_tries.get(address) + if trie is None: + trie = Trie(secured=True, default=U256(0)) + state._storage_tries[address] = trie + trie_set(trie, key, value) + if trie._data == {}: + del state._storage_tries[address] + + +def storage_root(state: State, address: Address) -> Root: + """ + Calculate the storage root of an account. + + Parameters + ---------- + state: + The state + address : + Address of the account. + + Returns + ------- + root : `Root` + Storage root of the account. + + """ + assert not state._snapshots + if address in state._storage_tries: + return root(state._storage_tries[address]) + else: + return EMPTY_TRIE_ROOT + + +def state_root(state: State) -> Root: + """ + Calculate the state root. + + Parameters + ---------- + state: + The current state. + + Returns + ------- + root : `Root` + The state root. + + """ + assert not state._snapshots + + def get_storage_root(address: Address) -> Root: + return storage_root(state, address) + + return root(state._main_trie, get_storage_root=get_storage_root) + + +def account_exists(state: State, address: Address) -> bool: + """ + Checks if an account exists in the state trie. + + Parameters + ---------- + state: + The state + address: + Address of the account that needs to be checked. + + Returns + ------- + account_exists : `bool` + True if account exists in the state trie, False otherwise + + """ + return get_account_optional(state, address) is not None + + +def account_has_code_or_nonce(state: State, address: Address) -> bool: + """ + Checks if an account has non zero nonce or non empty code. + + Parameters + ---------- + state: + The state + address: + Address of the account that needs to be checked. + + Returns + ------- + has_code_or_nonce : `bool` + True if the account has non zero nonce or non empty code, + False otherwise. + + """ + account = get_account(state, address) + return account.nonce != Uint(0) or account.code != b"" + + +def account_has_storage(state: State, address: Address) -> bool: + """ + Checks if an account has storage. + + Parameters + ---------- + state: + The state + address: + Address of the account that needs to be checked. + + Returns + ------- + has_storage : `bool` + True if the account has storage, False otherwise. + + """ + return address in state._storage_tries + + +def is_account_alive(state: State, address: Address) -> bool: + """ + Check whether an account is both in the state and non-empty. + + Parameters + ---------- + state: + The state + address: + Address of the account that needs to be checked. + + Returns + ------- + is_alive : `bool` + True if the account is alive. + + """ + account = get_account_optional(state, address) + return account is not None and account != EMPTY_ACCOUNT + + +def modify_state( + state: State, address: Address, f: Callable[[Account], None] +) -> None: + """ + Modify an `Account` in the `State`. If, after modification, the account + exists and has zero nonce, empty code, and zero balance, it is destroyed. + """ + set_account(state, address, modify(get_account(state, address), f)) + + account = get_account_optional(state, address) + account_exists_and_is_empty = ( + account is not None + and account.nonce == Uint(0) + and account.code == b"" + and account.balance == 0 + ) + + if account_exists_and_is_empty: + destroy_account(state, address) + + +def move_ether( + state: State, + sender_address: Address, + recipient_address: Address, + amount: U256, +) -> None: + """ + Move funds between accounts. + """ + + def reduce_sender_balance(sender: Account) -> None: + if sender.balance < amount: + raise AssertionError + sender.balance -= amount + + def increase_recipient_balance(recipient: Account) -> None: + recipient.balance += amount + + modify_state(state, sender_address, reduce_sender_balance) + modify_state(state, recipient_address, increase_recipient_balance) + + +def set_account_balance(state: State, address: Address, amount: U256) -> None: + """ + Sets the balance of an account. + + Parameters + ---------- + state: + The current state. + + address: + Address of the account whose nonce needs to be incremented. + + amount: + The amount that needs to set in balance. + + """ + + def set_balance(account: Account) -> None: + account.balance = amount + + modify_state(state, address, set_balance) + + +def increment_nonce(state: State, address: Address) -> None: + """ + Increments the nonce of an account. + + Parameters + ---------- + state: + The current state. + + address: + Address of the account whose nonce needs to be incremented. + + """ + + def increase_nonce(sender: Account) -> None: + sender.nonce += Uint(1) + + modify_state(state, address, increase_nonce) + + +def set_code(state: State, address: Address, code: Bytes) -> None: + """ + Sets Account code. + + Parameters + ---------- + state: + The current state. + + address: + Address of the account whose code needs to be update. + + code: + The bytecode that needs to be set. + + """ + + def write_code(sender: Account) -> None: + sender.code = code + + modify_state(state, address, write_code) + + +def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: + """ + Get the original value in a storage slot i.e. the value before the current + transaction began. This function reads the value from the snapshots taken + before executing the transaction. + + Parameters + ---------- + state: + The current state. + address: + Address of the account to read the value from. + key: + Key of the storage slot. + + """ + # In the transaction where an account is created, its preexisting storage + # is ignored. + if address in state.created_accounts: + return U256(0) + + _, original_trie = state._snapshots[0] + original_account_trie = original_trie.get(address) + + if original_account_trie is None: + original_value = U256(0) + else: + original_value = trie_get(original_account_trie, key) + + assert isinstance(original_value, U256) + + return original_value + + +def get_transient_storage( + transient_storage: TransientStorage, address: Address, key: Bytes32 +) -> U256: + """ + Get a value at a storage key on an account from transient storage. + Returns `U256(0)` if the storage key has not been set previously. + + Parameters + ---------- + transient_storage: `TransientStorage` + The transient storage + address : `Address` + Address of the account. + key : `Bytes` + Key to lookup. + + Returns + ------- + value : `U256` + Value at the key. + + """ + trie = transient_storage._tries.get(address) + if trie is None: + return U256(0) + + value = trie_get(trie, key) + + assert isinstance(value, U256) + return value + + +def set_transient_storage( + transient_storage: TransientStorage, + address: Address, + key: Bytes32, + value: U256, +) -> None: + """ + Set a value at a storage key on an account. Setting to `U256(0)` deletes + the key. + + Parameters + ---------- + transient_storage: `TransientStorage` + The transient storage + address : `Address` + Address of the account. + key : `Bytes` + Key to set. + value : `U256` + Value to set at the key. + + """ + trie = transient_storage._tries.get(address) + if trie is None: + trie = Trie(secured=True, default=U256(0)) + transient_storage._tries[address] = trie + trie_set(trie, key, value) + if trie._data == {}: + del transient_storage._tries[address] diff --git a/src/ethereum/forks/amsterdam/transactions.py b/src/ethereum/forks/amsterdam/transactions.py new file mode 100644 index 0000000000..bea74819ef --- /dev/null +++ b/src/ethereum/forks/amsterdam/transactions.py @@ -0,0 +1,887 @@ +""" +Transactions are atomic units of work created externally to Ethereum and +submitted to be executed. If Ethereum is viewed as a state machine, +transactions are the events that move between states. +""" + +from dataclasses import dataclass +from typing import Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes0, Bytes32 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64, U256, Uint, ulen + +from ethereum.crypto.elliptic_curve import SECP256K1N, secp256k1_recover +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.exceptions import ( + InsufficientTransactionGasError, + InvalidSignatureError, + NonceOverflowError, +) + +from .exceptions import ( + InitCodeTooLargeError, + TransactionGasLimitExceededError, + TransactionTypeError, +) +from .fork_types import Address, Authorization, VersionedHash + +TX_BASE_COST = Uint(21000) +""" +Base cost of a transaction in gas units. This is the minimum amount of gas +required to execute a transaction. +""" + +FLOOR_CALLDATA_COST = Uint(10) +""" +Minimum gas cost per byte of calldata as per [EIP-7623]. Used to calculate +the minimum gas cost for transactions that include calldata. + +[EIP-7623]: https://eips.ethereum.org/EIPS/eip-7623 +""" + +STANDARD_CALLDATA_TOKEN_COST = Uint(4) +""" +Gas cost per byte of calldata as per [EIP-7623]. Used to calculate the +gas cost for transactions that include calldata. + +[EIP-7623]: https://eips.ethereum.org/EIPS/eip-7623 +""" + +TX_CREATE_COST = Uint(32000) +""" +Additional gas cost for creating a new contract. +""" + +TX_ACCESS_LIST_ADDRESS_COST = Uint(2400) +""" +Gas cost for including an address in the access list of a transaction. +""" + +TX_ACCESS_LIST_STORAGE_KEY_COST = Uint(1900) +""" +Gas cost for including a storage key in the access list of a transaction. +""" + +TX_MAX_GAS_LIMIT = Uint(16_777_216) + + +@slotted_freezable +@dataclass +class LegacyTransaction: + """ + Atomic operation performed on the block chain. This represents the original + transaction format used before [EIP-1559], [EIP-2930], [EIP-4844], + and [EIP-7702]. + + [EIP-1559]: https://eips.ethereum.org/EIPS/eip-1559 + [EIP-2930]: https://eips.ethereum.org/EIPS/eip-2930 + [EIP-4844]: https://eips.ethereum.org/EIPS/eip-4844 + [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 + """ + + nonce: U256 + """ + A scalar value equal to the number of transactions sent by the sender. + """ + + gas_price: Uint + """ + The price of gas for this transaction, in wei. + """ + + gas: Uint + """ + The maximum amount of gas that can be used by this transaction. + """ + + to: Bytes0 | Address + """ + The address of the recipient. If empty, the transaction is a contract + creation. + """ + + value: U256 + """ + The amount of ether (in wei) to send with this transaction. + """ + + data: Bytes + """ + The data payload of the transaction, which can be used to call functions + on contracts or to create new contracts. + """ + + v: U256 + """ + The recovery id of the signature. + """ + + r: U256 + """ + The first part of the signature. + """ + + s: U256 + """ + The second part of the signature. + """ + + +@slotted_freezable +@dataclass +class Access: + """ + A mapping from account address to storage slots that are pre-warmed as part + of a transaction. + """ + + account: Address + """ + The address of the account that is accessed. + """ + + slots: Tuple[Bytes32, ...] + """ + A tuple of storage slots that are accessed in the account. + """ + + +@slotted_freezable +@dataclass +class AccessListTransaction: + """ + The transaction type added in [EIP-2930] to support access lists. + + This transaction type extends the legacy transaction with an access list + and chain ID. The access list specifies which addresses and storage slots + the transaction will access. + + [EIP-2930]: https://eips.ethereum.org/EIPS/eip-2930 + """ + + chain_id: U64 + """ + The ID of the chain on which this transaction is executed. + """ + + nonce: U256 + """ + A scalar value equal to the number of transactions sent by the sender. + """ + + gas_price: Uint + """ + The price of gas for this transaction. + """ + + gas: Uint + """ + The maximum amount of gas that can be used by this transaction. + """ + + to: Bytes0 | Address + """ + The address of the recipient. If empty, the transaction is a contract + creation. + """ + + value: U256 + """ + The amount of ether (in wei) to send with this transaction. + """ + + data: Bytes + """ + The data payload of the transaction, which can be used to call functions + on contracts or to create new contracts. + """ + + access_list: Tuple[Access, ...] + """ + A tuple of `Access` objects that specify which addresses and storage slots + are accessed in the transaction. + """ + + y_parity: U256 + """ + The recovery id of the signature. + """ + + r: U256 + """ + The first part of the signature. + """ + + s: U256 + """ + The second part of the signature. + """ + + +@slotted_freezable +@dataclass +class FeeMarketTransaction: + """ + The transaction type added in [EIP-1559]. + + This transaction type introduces a new fee market mechanism with two gas + price parameters: max_priority_fee_per_gas and max_fee_per_gas. + + [EIP-1559]: https://eips.ethereum.org/EIPS/eip-1559 + """ + + chain_id: U64 + """ + The ID of the chain on which this transaction is executed. + """ + + nonce: U256 + """ + A scalar value equal to the number of transactions sent by the sender. + """ + + max_priority_fee_per_gas: Uint + """ + The maximum priority fee per gas that the sender is willing to pay. + """ + + max_fee_per_gas: Uint + """ + The maximum fee per gas that the sender is willing to pay, including the + base fee and priority fee. + """ + + gas: Uint + """ + The maximum amount of gas that can be used by this transaction. + """ + + to: Bytes0 | Address + """ + The address of the recipient. If empty, the transaction is a contract + creation. + """ + + value: U256 + """ + The amount of ether (in wei) to send with this transaction. + """ + + data: Bytes + """ + The data payload of the transaction, which can be used to call functions + on contracts or to create new contracts. + """ + + access_list: Tuple[Access, ...] + """ + A tuple of `Access` objects that specify which addresses and storage slots + are accessed in the transaction. + """ + + y_parity: U256 + """ + The recovery id of the signature. + """ + + r: U256 + """ + The first part of the signature. + """ + + s: U256 + """ + The second part of the signature. + """ + + +@slotted_freezable +@dataclass +class BlobTransaction: + """ + The transaction type added in [EIP-4844]. + + This transaction type extends the fee market transaction to support + blob-carrying transactions. + + [EIP-4844]: https://eips.ethereum.org/EIPS/eip-4844 + """ + + chain_id: U64 + """ + The ID of the chain on which this transaction is executed. + """ + + nonce: U256 + """ + A scalar value equal to the number of transactions sent by the sender. + """ + + max_priority_fee_per_gas: Uint + """ + The maximum priority fee per gas that the sender is willing to pay. + """ + + max_fee_per_gas: Uint + """ + The maximum fee per gas that the sender is willing to pay, including the + base fee and priority fee. + """ + + gas: Uint + """ + The maximum amount of gas that can be used by this transaction. + """ + + to: Address + """ + The address of the recipient. If empty, the transaction is a contract + creation. + """ + + value: U256 + """ + The amount of ether (in wei) to send with this transaction. + """ + + data: Bytes + """ + The data payload of the transaction, which can be used to call functions + on contracts or to create new contracts. + """ + + access_list: Tuple[Access, ...] + """ + A tuple of `Access` objects that specify which addresses and storage slots + are accessed in the transaction. + """ + + max_fee_per_blob_gas: U256 + """ + The maximum fee per blob gas that the sender is willing to pay. + """ + + blob_versioned_hashes: Tuple[VersionedHash, ...] + """ + A tuple of objects that represent the versioned hashes of the blobs + included in the transaction. + """ + + y_parity: U256 + """ + The recovery id of the signature. + """ + + r: U256 + """ + The first part of the signature. + """ + + s: U256 + """ + The second part of the signature. + """ + + +@slotted_freezable +@dataclass +class SetCodeTransaction: + """ + The transaction type added in [EIP-7702]. + + This transaction type allows Ethereum Externally Owned Accounts (EOAs) + to set code on their account, enabling them to act as smart contracts. + + [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 + """ + + chain_id: U64 + """ + The ID of the chain on which this transaction is executed. + """ + + nonce: U64 + """ + A scalar value equal to the number of transactions sent by the sender. + """ + + max_priority_fee_per_gas: Uint + """ + The maximum priority fee per gas that the sender is willing to pay. + """ + + max_fee_per_gas: Uint + """ + The maximum fee per gas that the sender is willing to pay, including the + base fee and priority fee. + """ + + gas: Uint + """ + The maximum amount of gas that can be used by this transaction. + """ + + to: Address + """ + The address of the recipient. If empty, the transaction is a contract + creation. + """ + + value: U256 + """ + The amount of ether (in wei) to send with this transaction. + """ + + data: Bytes + """ + The data payload of the transaction, which can be used to call functions + on contracts or to create new contracts. + """ + + access_list: Tuple[Access, ...] + """ + A tuple of `Access` objects that specify which addresses and storage slots + are accessed in the transaction. + """ + + authorizations: Tuple[Authorization, ...] + """ + A tuple of `Authorization` objects that specify what code the signer + desires to execute in the context of their EOA. + """ + + y_parity: U256 + """ + The recovery id of the signature. + """ + + r: U256 + """ + The first part of the signature. + """ + + s: U256 + """ + The second part of the signature. + """ + + +Transaction = ( + LegacyTransaction + | AccessListTransaction + | FeeMarketTransaction + | BlobTransaction + | SetCodeTransaction +) +""" +Union type representing any valid transaction type. +""" + + +def encode_transaction(tx: Transaction) -> LegacyTransaction | Bytes: + """ + Encode a transaction into its RLP or typed transaction format. + Needed because non-legacy transactions aren't RLP. + + Legacy transactions are returned as-is, while other transaction types + are prefixed with their type identifier and RLP encoded. + """ + if isinstance(tx, LegacyTransaction): + return tx + elif isinstance(tx, AccessListTransaction): + return b"\x01" + rlp.encode(tx) + elif isinstance(tx, FeeMarketTransaction): + return b"\x02" + rlp.encode(tx) + elif isinstance(tx, BlobTransaction): + return b"\x03" + rlp.encode(tx) + elif isinstance(tx, SetCodeTransaction): + return b"\x04" + rlp.encode(tx) + else: + raise Exception(f"Unable to encode transaction of type {type(tx)}") + + +def decode_transaction(tx: LegacyTransaction | Bytes) -> Transaction: + """ + Decode a transaction from its RLP or typed transaction format. + Needed because non-legacy transactions aren't RLP. + + Legacy transactions are returned as-is, while other transaction types + are decoded based on their type identifier prefix. + """ + if isinstance(tx, Bytes): + if tx[0] == 1: + return rlp.decode_to(AccessListTransaction, tx[1:]) + elif tx[0] == 2: + return rlp.decode_to(FeeMarketTransaction, tx[1:]) + elif tx[0] == 3: + return rlp.decode_to(BlobTransaction, tx[1:]) + elif tx[0] == 4: + return rlp.decode_to(SetCodeTransaction, tx[1:]) + else: + raise TransactionTypeError(tx[0]) + else: + return tx + + +def validate_transaction(tx: Transaction) -> Tuple[Uint, Uint]: + """ + Verifies a transaction. + + The gas in a transaction gets used to pay for the intrinsic cost of + operations, therefore if there is insufficient gas then it would not + be possible to execute a transaction and it will be declared invalid. + + Additionally, the nonce of a transaction must not equal or exceed the + limit defined in [EIP-2681]. + In practice, defining the limit as ``2**64-1`` has no impact because + sending ``2**64-1`` transactions is improbable. It's not strictly + impossible though, ``2**64-1`` transactions is the entire capacity of the + Ethereum blockchain at 2022 gas limits for a little over 22 years. + + Also, the code size of a contract creation transaction must be within + limits of the protocol. + + This function takes a transaction as a parameter and returns the intrinsic + gas cost and the minimum calldata gas cost for the transaction after + validation. It throws an `InsufficientTransactionGasError` exception if + the transaction does not provide enough gas to cover the intrinsic cost, + and a `NonceOverflowError` exception if the nonce is greater than + `2**64 - 2`. It also raises an `InitCodeTooLargeError` if the code size of + a contract creation transaction exceeds the maximum allowed size. + + [EIP-2681]: https://eips.ethereum.org/EIPS/eip-2681 + [EIP-7623]: https://eips.ethereum.org/EIPS/eip-7623 + """ + from .vm.interpreter import MAX_INIT_CODE_SIZE + + intrinsic_gas, calldata_floor_gas_cost = calculate_intrinsic_cost(tx) + if max(intrinsic_gas, calldata_floor_gas_cost) > tx.gas: + raise InsufficientTransactionGasError("Insufficient gas") + if U256(tx.nonce) >= U256(U64.MAX_VALUE): + raise NonceOverflowError("Nonce too high") + if tx.to == Bytes0(b"") and len(tx.data) > MAX_INIT_CODE_SIZE: + raise InitCodeTooLargeError("Code size too large") + if tx.gas > TX_MAX_GAS_LIMIT: + raise TransactionGasLimitExceededError("Gas limit too high") + + return intrinsic_gas, calldata_floor_gas_cost + + +def calculate_intrinsic_cost(tx: Transaction) -> Tuple[Uint, Uint]: + """ + Calculates the gas that is charged before execution is started. + + The intrinsic cost of the transaction is charged before execution has + begun. Functions/operations in the EVM cost money to execute so this + intrinsic cost is for the operations that need to be paid for as part of + the transaction. Data transfer, for example, is part of this intrinsic + cost. It costs ether to send data over the wire and that ether is + accounted for in the intrinsic cost calculated in this function. This + intrinsic cost must be calculated and paid for before execution in order + for all operations to be implemented. + + The intrinsic cost includes: + 1. Base cost (`TX_BASE_COST`) + 2. Cost for data (zero and non-zero bytes) + 3. Cost for contract creation (if applicable) + 4. Cost for access list entries (if applicable) + 5. Cost for authorizations (if applicable) + + + This function takes a transaction as a parameter and returns the intrinsic + gas cost of the transaction and the minimum gas cost used by the + transaction based on the calldata size. + """ + from .vm.eoa_delegation import PER_EMPTY_ACCOUNT_COST + from .vm.gas import init_code_cost + + zero_bytes = 0 + for byte in tx.data: + if byte == 0: + zero_bytes += 1 + + tokens_in_calldata = Uint(zero_bytes + (len(tx.data) - zero_bytes) * 4) + # EIP-7623 floor price (note: no EVM costs) + calldata_floor_gas_cost = ( + tokens_in_calldata * FLOOR_CALLDATA_COST + TX_BASE_COST + ) + + data_cost = tokens_in_calldata * STANDARD_CALLDATA_TOKEN_COST + + if tx.to == Bytes0(b""): + create_cost = TX_CREATE_COST + init_code_cost(ulen(tx.data)) + else: + create_cost = Uint(0) + + access_list_cost = Uint(0) + if isinstance( + tx, + ( + AccessListTransaction, + FeeMarketTransaction, + BlobTransaction, + SetCodeTransaction, + ), + ): + for access in tx.access_list: + access_list_cost += TX_ACCESS_LIST_ADDRESS_COST + access_list_cost += ( + ulen(access.slots) * TX_ACCESS_LIST_STORAGE_KEY_COST + ) + + auth_cost = Uint(0) + if isinstance(tx, SetCodeTransaction): + auth_cost += Uint(PER_EMPTY_ACCOUNT_COST * len(tx.authorizations)) + + return ( + Uint( + TX_BASE_COST + + data_cost + + create_cost + + access_list_cost + + auth_cost + ), + calldata_floor_gas_cost, + ) + + +def recover_sender(chain_id: U64, tx: Transaction) -> Address: + """ + Extracts the sender address from a transaction. + + The v, r, and s values are the three parts that make up the signature + of a transaction. In order to recover the sender of a transaction the two + components needed are the signature (``v``, ``r``, and ``s``) and the + signing hash of the transaction. The sender's public key can be obtained + with these two values and therefore the sender address can be retrieved. + + This function takes chain_id and a transaction as parameters and returns + the address of the sender of the transaction. It raises an + `InvalidSignatureError` if the signature values (r, s, v) are invalid. + """ + r, s = tx.r, tx.s + if U256(0) >= r or r >= SECP256K1N: + raise InvalidSignatureError("bad r") + if U256(0) >= s or s > SECP256K1N // U256(2): + raise InvalidSignatureError("bad s") + + if isinstance(tx, LegacyTransaction): + v = tx.v + if v == 27 or v == 28: + public_key = secp256k1_recover( + r, s, v - U256(27), signing_hash_pre155(tx) + ) + else: + chain_id_x2 = U256(chain_id) * U256(2) + if v != U256(35) + chain_id_x2 and v != U256(36) + chain_id_x2: + raise InvalidSignatureError("bad v") + public_key = secp256k1_recover( + r, + s, + v - U256(35) - chain_id_x2, + signing_hash_155(tx, chain_id), + ) + elif isinstance(tx, AccessListTransaction): + if tx.y_parity not in (U256(0), U256(1)): + raise InvalidSignatureError("bad y_parity") + public_key = secp256k1_recover( + r, s, tx.y_parity, signing_hash_2930(tx) + ) + elif isinstance(tx, FeeMarketTransaction): + if tx.y_parity not in (U256(0), U256(1)): + raise InvalidSignatureError("bad y_parity") + public_key = secp256k1_recover( + r, s, tx.y_parity, signing_hash_1559(tx) + ) + elif isinstance(tx, BlobTransaction): + if tx.y_parity not in (U256(0), U256(1)): + raise InvalidSignatureError("bad y_parity") + public_key = secp256k1_recover( + r, s, tx.y_parity, signing_hash_4844(tx) + ) + elif isinstance(tx, SetCodeTransaction): + if tx.y_parity not in (U256(0), U256(1)): + raise InvalidSignatureError("bad y_parity") + public_key = secp256k1_recover( + r, s, tx.y_parity, signing_hash_7702(tx) + ) + + return Address(keccak256(public_key)[12:32]) + + +def signing_hash_pre155(tx: LegacyTransaction) -> Hash32: + """ + Compute the hash of a transaction used in a legacy (pre [EIP-155]) + signature. + + This function takes a legacy transaction as a parameter and returns the + signing hash of the transaction. + + [EIP-155]: https://eips.ethereum.org/EIPS/eip-155 + """ + return keccak256( + rlp.encode( + ( + tx.nonce, + tx.gas_price, + tx.gas, + tx.to, + tx.value, + tx.data, + ) + ) + ) + + +def signing_hash_155(tx: LegacyTransaction, chain_id: U64) -> Hash32: + """ + Compute the hash of a transaction used in a [EIP-155] signature. + + This function takes a legacy transaction and a chain ID as parameters + and returns the hash of the transaction used in an [EIP-155] signature. + + [EIP-155]: https://eips.ethereum.org/EIPS/eip-155 + """ + return keccak256( + rlp.encode( + ( + tx.nonce, + tx.gas_price, + tx.gas, + tx.to, + tx.value, + tx.data, + chain_id, + Uint(0), + Uint(0), + ) + ) + ) + + +def signing_hash_2930(tx: AccessListTransaction) -> Hash32: + """ + Compute the hash of a transaction used in a [EIP-2930] signature. + + This function takes an access list transaction as a parameter + and returns the hash of the transaction used in an [EIP-2930] signature. + + [EIP-2930]: https://eips.ethereum.org/EIPS/eip-2930 + """ + return keccak256( + b"\x01" + + rlp.encode( + ( + tx.chain_id, + tx.nonce, + tx.gas_price, + tx.gas, + tx.to, + tx.value, + tx.data, + tx.access_list, + ) + ) + ) + + +def signing_hash_1559(tx: FeeMarketTransaction) -> Hash32: + """ + Compute the hash of a transaction used in an [EIP-1559] signature. + + This function takes a fee market transaction as a parameter + and returns the hash of the transaction used in an [EIP-1559] signature. + + [EIP-1559]: https://eips.ethereum.org/EIPS/eip-1559 + """ + return keccak256( + b"\x02" + + rlp.encode( + ( + tx.chain_id, + tx.nonce, + tx.max_priority_fee_per_gas, + tx.max_fee_per_gas, + tx.gas, + tx.to, + tx.value, + tx.data, + tx.access_list, + ) + ) + ) + + +def signing_hash_4844(tx: BlobTransaction) -> Hash32: + """ + Compute the hash of a transaction used in an [EIP-4844] signature. + + This function takes a transaction as a parameter and returns the + signing hash of the transaction used in an [EIP-4844] signature. + + [EIP-4844]: https://eips.ethereum.org/EIPS/eip-4844 + """ + return keccak256( + b"\x03" + + rlp.encode( + ( + tx.chain_id, + tx.nonce, + tx.max_priority_fee_per_gas, + tx.max_fee_per_gas, + tx.gas, + tx.to, + tx.value, + tx.data, + tx.access_list, + tx.max_fee_per_blob_gas, + tx.blob_versioned_hashes, + ) + ) + ) + + +def signing_hash_7702(tx: SetCodeTransaction) -> Hash32: + """ + Compute the hash of a transaction used in a [EIP-7702] signature. + + This function takes a transaction as a parameter and returns the + signing hash of the transaction used in a [EIP-7702] signature. + + [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 + """ + return keccak256( + b"\x04" + + rlp.encode( + ( + tx.chain_id, + tx.nonce, + tx.max_priority_fee_per_gas, + tx.max_fee_per_gas, + tx.gas, + tx.to, + tx.value, + tx.data, + tx.access_list, + tx.authorizations, + ) + ) + ) + + +def get_transaction_hash(tx: Bytes | LegacyTransaction) -> Hash32: + """ + Compute the hash of a transaction. + + This function takes a transaction as a parameter and returns the + keccak256 hash of the transaction. It can handle both legacy transactions + and typed transactions (`AccessListTransaction`, `FeeMarketTransaction`, + etc.). + """ + assert isinstance(tx, (LegacyTransaction, Bytes)) + if isinstance(tx, LegacyTransaction): + return keccak256(rlp.encode(tx)) + else: + return keccak256(tx) diff --git a/src/ethereum/forks/amsterdam/trie.py b/src/ethereum/forks/amsterdam/trie.py new file mode 100644 index 0000000000..11a2e035ab --- /dev/null +++ b/src/ethereum/forks/amsterdam/trie.py @@ -0,0 +1,508 @@ +""" +State Trie. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +The state trie is the structure responsible for storing +`.fork_types.Account` objects. +""" + +import copy +from dataclasses import dataclass, field +from typing import ( + Callable, + Dict, + Generic, + List, + Mapping, + MutableMapping, + Optional, + Sequence, + Tuple, + TypeVar, + cast, +) + +from ethereum_rlp import Extended, rlp +from ethereum_types.bytes import Bytes +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U256, Uint +from typing_extensions import assert_type + +from ethereum.crypto.hash import keccak256 +from ethereum.forks.osaka import trie as previous_trie +from ethereum.utils.hexadecimal import hex_to_bytes + +from .blocks import Receipt, Withdrawal +from .fork_types import Account, Address, Root, encode_account +from .transactions import LegacyTransaction + +# note: an empty trie (regardless of whether it is secured) has root: +# +# keccak256(RLP(b'')) +# == +# 56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421 # noqa: E501 +# +# also: +# +# keccak256(RLP(())) +# == +# 1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347 # noqa: E501 +# +# which is the sha3Uncles hash in block header with no uncles +EMPTY_TRIE_ROOT = Root( + hex_to_bytes( + "56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421" + ) +) + +Node = ( + Account + | Bytes + | LegacyTransaction + | Receipt + | Uint + | U256 + | Withdrawal + | None +) +K = TypeVar("K", bound=Bytes) +V = TypeVar( + "V", + Optional[Account], + Optional[Bytes], + Bytes, + Optional[LegacyTransaction | Bytes], + Optional[Receipt | Bytes], + Optional[Withdrawal | Bytes], + Uint, + U256, +) + + +@slotted_freezable +@dataclass +class LeafNode: + """Leaf node in the Merkle Trie.""" + + rest_of_key: Bytes + value: Extended + + +@slotted_freezable +@dataclass +class ExtensionNode: + """Extension node in the Merkle Trie.""" + + key_segment: Bytes + subnode: Extended + + +BranchSubnodes = Tuple[ + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, + Extended, +] + + +@slotted_freezable +@dataclass +class BranchNode: + """Branch node in the Merkle Trie.""" + + subnodes: BranchSubnodes + value: Extended + + +InternalNode = LeafNode | ExtensionNode | BranchNode + + +def encode_internal_node(node: Optional[InternalNode]) -> Extended: + """ + Encodes a Merkle Trie node into its RLP form. The RLP will then be + serialized into a `Bytes` and hashed unless it is less that 32 bytes + when serialized. + + This function also accepts `None`, representing the absence of a node, + which is encoded to `b""`. + + Parameters + ---------- + node : Optional[InternalNode] + The node to encode. + + Returns + ------- + encoded : `Extended` + The node encoded as RLP. + + """ + unencoded: Extended + if node is None: + unencoded = b"" + elif isinstance(node, LeafNode): + unencoded = ( + nibble_list_to_compact(node.rest_of_key, True), + node.value, + ) + elif isinstance(node, ExtensionNode): + unencoded = ( + nibble_list_to_compact(node.key_segment, False), + node.subnode, + ) + elif isinstance(node, BranchNode): + unencoded = list(node.subnodes) + [node.value] + else: + raise AssertionError(f"Invalid internal node type {type(node)}!") + + encoded = rlp.encode(unencoded) + if len(encoded) < 32: + return unencoded + else: + return keccak256(encoded) + + +def encode_node(node: Node, storage_root: Optional[Bytes] = None) -> Bytes: + """ + Encode a Node for storage in the Merkle Trie. + + Currently mostly an unimplemented stub. + """ + if isinstance(node, Account): + assert storage_root is not None + return encode_account(node, storage_root) + elif isinstance(node, (LegacyTransaction, Receipt, Withdrawal, U256)): + return rlp.encode(node) + elif isinstance(node, Bytes): + return node + else: + return previous_trie.encode_node(node, storage_root) + + +@dataclass +class Trie(Generic[K, V]): + """ + The Merkle Trie. + """ + + secured: bool + default: V + _data: Dict[K, V] = field(default_factory=dict) + + +def copy_trie(trie: Trie[K, V]) -> Trie[K, V]: + """ + Create a copy of `trie`. Since only frozen objects may be stored in tries, + the contents are reused. + + Parameters + ---------- + trie: `Trie` + Trie to copy. + + Returns + ------- + new_trie : `Trie[K, V]` + A copy of the trie. + + """ + return Trie(trie.secured, trie.default, copy.copy(trie._data)) + + +def trie_set(trie: Trie[K, V], key: K, value: V) -> None: + """ + Stores an item in a Merkle Trie. + + This method deletes the key if `value == trie.default`, because the Merkle + Trie represents the default value by omitting it from the trie. + + Parameters + ---------- + trie: `Trie` + Trie to store in. + key : `Bytes` + Key to lookup. + value : `V` + Node to insert at `key`. + + """ + if value == trie.default: + if key in trie._data: + del trie._data[key] + else: + trie._data[key] = value + + +def trie_get(trie: Trie[K, V], key: K) -> V: + """ + Gets an item from the Merkle Trie. + + This method returns `trie.default` if the key is missing. + + Parameters + ---------- + trie: + Trie to lookup in. + key : + Key to lookup. + + Returns + ------- + node : `V` + Node at `key` in the trie. + + """ + return trie._data.get(key, trie.default) + + +def common_prefix_length(a: Sequence, b: Sequence) -> int: + """ + Find the longest common prefix of two sequences. + """ + for i in range(len(a)): + if i >= len(b) or a[i] != b[i]: + return i + return len(a) + + +def nibble_list_to_compact(x: Bytes, is_leaf: bool) -> Bytes: + """ + Compresses nibble-list into a standard byte array with a flag. + + A nibble-list is a list of byte values no greater than `15`. The flag is + encoded in high nibble of the highest byte. The flag nibble can be broken + down into two two-bit flags. + + Highest nibble:: + + +---+---+----------+--------+ + | _ | _ | is_leaf | parity | + +---+---+----------+--------+ + 3 2 1 0 + + + The lowest bit of the nibble encodes the parity of the length of the + remaining nibbles -- `0` when even and `1` when odd. The second lowest bit + is used to distinguish leaf and extension nodes. The other two bits are not + used. + + Parameters + ---------- + x : + Array of nibbles. + is_leaf : + True if this is part of a leaf node, or false if it is an extension + node. + + Returns + ------- + compressed : `bytearray` + Compact byte array. + + """ + compact = bytearray() + + if len(x) % 2 == 0: # ie even length + compact.append(16 * (2 * is_leaf)) + for i in range(0, len(x), 2): + compact.append(16 * x[i] + x[i + 1]) + else: + compact.append(16 * ((2 * is_leaf) + 1) + x[0]) + for i in range(1, len(x), 2): + compact.append(16 * x[i] + x[i + 1]) + + return Bytes(compact) + + +def bytes_to_nibble_list(bytes_: Bytes) -> Bytes: + """ + Converts a `Bytes` into to a sequence of nibbles (bytes with value < 16). + + Parameters + ---------- + bytes_: + The `Bytes` to convert. + + Returns + ------- + nibble_list : `Bytes` + The `Bytes` in nibble-list format. + + """ + nibble_list = bytearray(2 * len(bytes_)) + for byte_index, byte in enumerate(bytes_): + nibble_list[byte_index * 2] = (byte & 0xF0) >> 4 + nibble_list[byte_index * 2 + 1] = byte & 0x0F + return Bytes(nibble_list) + + +def _prepare_trie( + trie: Trie[K, V], + get_storage_root: Optional[Callable[[Address], Root]] = None, +) -> Mapping[Bytes, Bytes]: + """ + Prepares the trie for root calculation. Removes values that are empty, + hashes the keys (if `secured == True`) and encodes all the nodes. + + Parameters + ---------- + trie : + The `Trie` to prepare. + get_storage_root : + Function to get the storage root of an account. Needed to encode + `Account` objects. + + Returns + ------- + out : `Mapping[ethereum.base_types.Bytes, Node]` + Object with keys mapped to nibble-byte form. + + """ + mapped: MutableMapping[Bytes, Bytes] = {} + + for preimage, value in trie._data.items(): + if isinstance(value, Account): + assert get_storage_root is not None + address = Address(preimage) + encoded_value = encode_node(value, get_storage_root(address)) + else: + encoded_value = encode_node(value) + if encoded_value == b"": + raise AssertionError + key: Bytes + if trie.secured: + # "secure" tries hash keys once before construction + key = keccak256(preimage) + else: + key = preimage + mapped[bytes_to_nibble_list(key)] = encoded_value + + return mapped + + +def root( + trie: Trie[K, V], + get_storage_root: Optional[Callable[[Address], Root]] = None, +) -> Root: + """ + Computes the root of a modified merkle patricia trie (MPT). + + Parameters + ---------- + trie : + `Trie` to get the root of. + get_storage_root : + Function to get the storage root of an account. Needed to encode + `Account` objects. + + + Returns + ------- + root : `.fork_types.Root` + MPT root of the underlying key-value pairs. + + """ + obj = _prepare_trie(trie, get_storage_root) + + root_node = encode_internal_node(patricialize(obj, Uint(0))) + if len(rlp.encode(root_node)) < 32: + return keccak256(rlp.encode(root_node)) + else: + assert isinstance(root_node, Bytes) + return Root(root_node) + + +def patricialize( + obj: Mapping[Bytes, Bytes], level: Uint +) -> Optional[InternalNode]: + """ + Structural composition function. + + Used to recursively patricialize and merkleize a dictionary. Includes + memoization of the tree structure and hashes. + + Parameters + ---------- + obj : + Underlying trie key-value pairs, with keys in nibble-list format. + level : + Current trie level. + + Returns + ------- + node : `ethereum.base_types.Bytes` + Root node of `obj`. + + """ + if len(obj) == 0: + return None + + arbitrary_key = next(iter(obj)) + + # if leaf node + if len(obj) == 1: + leaf = LeafNode(arbitrary_key[level:], obj[arbitrary_key]) + return leaf + + # prepare for extension node check by finding max j such that all keys in + # obj have the same key[i:j] + substring = arbitrary_key[level:] + prefix_length = len(substring) + for key in obj: + prefix_length = min( + prefix_length, common_prefix_length(substring, key[level:]) + ) + + # finished searching, found another key at the current level + if prefix_length == 0: + break + + # if extension node + if prefix_length > 0: + prefix = arbitrary_key[int(level) : int(level) + prefix_length] + return ExtensionNode( + prefix, + encode_internal_node( + patricialize(obj, level + Uint(prefix_length)) + ), + ) + + branches: List[MutableMapping[Bytes, Bytes]] = [] + for _ in range(16): + branches.append({}) + value = b"" + for key in obj: + if len(key) == level: + # shouldn't ever have an account or receipt in an internal node + if isinstance(obj[key], (Account, Receipt, Uint)): + raise AssertionError + value = obj[key] + else: + branches[key[level]][key] = obj[key] + + subnodes = tuple( + encode_internal_node(patricialize(branches[k], level + Uint(1))) + for k in range(16) + ) + return BranchNode( + cast(BranchSubnodes, assert_type(subnodes, Tuple[Extended, ...])), + value, + ) diff --git a/src/ethereum/forks/amsterdam/utils/__init__.py b/src/ethereum/forks/amsterdam/utils/__init__.py new file mode 100644 index 0000000000..224a4d269b --- /dev/null +++ b/src/ethereum/forks/amsterdam/utils/__init__.py @@ -0,0 +1,3 @@ +""" +Utility functions unique to this particular fork. +""" diff --git a/src/ethereum/forks/amsterdam/utils/address.py b/src/ethereum/forks/amsterdam/utils/address.py new file mode 100644 index 0000000000..270d562ca3 --- /dev/null +++ b/src/ethereum/forks/amsterdam/utils/address.py @@ -0,0 +1,94 @@ +""" +Hardfork Utility Functions For Addresses. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Address specific functions used in this amsterdam version of +specification. +""" + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U256, Uint + +from ethereum.crypto.hash import keccak256 +from ethereum.utils.byte import left_pad_zero_bytes + +from ..fork_types import Address + + +def to_address_masked(data: Uint | U256) -> Address: + """ + Convert a Uint or U256 value to a valid address (20 bytes). + + Parameters + ---------- + data : + The numeric value to be converted to address. + + Returns + ------- + address : `Address` + The obtained address. + + """ + return Address(data.to_be_bytes32()[-20:]) + + +def compute_contract_address(address: Address, nonce: Uint) -> Address: + """ + Computes address of the new account that needs to be created. + + Parameters + ---------- + address : + The address of the account that wants to create the new account. + nonce : + The transaction count of the account that wants to create the new + account. + + Returns + ------- + address: `Address` + The computed address of the new account. + + """ + computed_address = keccak256(rlp.encode([address, nonce])) + canonical_address = computed_address[-20:] + padded_address = left_pad_zero_bytes(canonical_address, 20) + return Address(padded_address) + + +def compute_create2_contract_address( + address: Address, salt: Bytes32, call_data: Bytes +) -> Address: + """ + Computes address of the new account that needs to be created, which is + based on the sender address, salt and the call data as well. + + Parameters + ---------- + address : + The address of the account that wants to create the new account. + salt : + Address generation salt. + call_data : + The code of the new account which is to be created. + + Returns + ------- + address: `ethereum.forks.amsterdam.fork_types.Address` + The computed address of the new account. + + """ + preimage = b"\xff" + address + salt + keccak256(call_data) + computed_address = keccak256(preimage) + canonical_address = computed_address[-20:] + padded_address = left_pad_zero_bytes(canonical_address, 20) + + return Address(padded_address) diff --git a/src/ethereum/forks/amsterdam/utils/hexadecimal.py b/src/ethereum/forks/amsterdam/utils/hexadecimal.py new file mode 100644 index 0000000000..23401e5d4f --- /dev/null +++ b/src/ethereum/forks/amsterdam/utils/hexadecimal.py @@ -0,0 +1,55 @@ +""" +Utility Functions For Hexadecimal Strings. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Hexadecimal utility functions used in this specification, specific to +Amsterdam types. +""" + +from ethereum_types.bytes import Bytes + +from ethereum.utils.hexadecimal import remove_hex_prefix + +from ..fork_types import Address, Root + + +def hex_to_root(hex_string: str) -> Root: + """ + Convert hex string to trie root. + + Parameters + ---------- + hex_string : + The hexadecimal string to be converted to trie root. + + Returns + ------- + root : `Root` + Trie root obtained from the given hexadecimal string. + + """ + return Root(Bytes.fromhex(remove_hex_prefix(hex_string))) + + +def hex_to_address(hex_string: str) -> Address: + """ + Convert hex string to Address (20 bytes). + + Parameters + ---------- + hex_string : + The hexadecimal string to be converted to Address. + + Returns + ------- + address : `Address` + The address obtained from the given hexadecimal string. + + """ + return Address(Bytes.fromhex(remove_hex_prefix(hex_string).rjust(40, "0"))) diff --git a/src/ethereum/forks/amsterdam/utils/message.py b/src/ethereum/forks/amsterdam/utils/message.py new file mode 100644 index 0000000000..107cdcaf7a --- /dev/null +++ b/src/ethereum/forks/amsterdam/utils/message.py @@ -0,0 +1,90 @@ +""" +Hardfork Utility Functions For The Message Data-structure. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Message specific functions used in this amsterdam version of +specification. +""" + +from ethereum_types.bytes import Bytes, Bytes0 +from ethereum_types.numeric import Uint + +from ..fork_types import Address +from ..state import get_account +from ..transactions import Transaction +from ..vm import BlockEnvironment, Message, TransactionEnvironment +from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS +from .address import compute_contract_address + + +def prepare_message( + block_env: BlockEnvironment, + tx_env: TransactionEnvironment, + tx: Transaction, +) -> Message: + """ + Execute a transaction against the provided environment. + + Parameters + ---------- + block_env : + Environment for the Ethereum Virtual Machine. + tx_env : + Environment for the transaction. + tx : + Transaction to be executed. + + Returns + ------- + message: `ethereum.forks.amsterdam.vm.Message` + Items containing contract creation or message call specific data. + + """ + accessed_addresses = set() + accessed_addresses.add(tx_env.origin) + accessed_addresses.update(PRE_COMPILED_CONTRACTS.keys()) + accessed_addresses.update(tx_env.access_list_addresses) + + if isinstance(tx.to, Bytes0): + current_target = compute_contract_address( + tx_env.origin, + get_account(block_env.state, tx_env.origin).nonce - Uint(1), + ) + msg_data = Bytes(b"") + code = tx.data + code_address = None + elif isinstance(tx.to, Address): + current_target = tx.to + msg_data = tx.data + code = get_account(block_env.state, tx.to).code + code_address = tx.to + else: + raise AssertionError("Target must be address or empty bytes") + + accessed_addresses.add(current_target) + + return Message( + block_env=block_env, + tx_env=tx_env, + caller=tx_env.origin, + target=tx.to, + gas=tx_env.gas, + value=tx.value, + data=msg_data, + code=code, + depth=Uint(0), + current_target=current_target, + code_address=code_address, + should_transfer_value=True, + is_static=False, + accessed_addresses=accessed_addresses, + accessed_storage_keys=set(tx_env.access_list_storage_keys), + disable_precompiles=False, + parent_evm=None, + ) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py new file mode 100644 index 0000000000..b2a8c5e2b9 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -0,0 +1,191 @@ +""" +Ethereum Virtual Machine (EVM). + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +The abstract computer which runs the code stored in an +`.fork_types.Account`. +""" + +from dataclasses import dataclass, field +from typing import List, Optional, Set, Tuple + +from ethereum_types.bytes import Bytes, Bytes0, Bytes32 +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.hash import Hash32 +from ethereum.exceptions import EthereumException + +from ..blocks import Log, Receipt, Withdrawal +from ..fork_types import Address, Authorization, VersionedHash +from ..state import State, TransientStorage +from ..transactions import LegacyTransaction +from ..trie import Trie + +__all__ = ("Environment", "Evm", "Message") + + +@dataclass +class BlockEnvironment: + """ + Items external to the virtual machine itself, provided by the environment. + """ + + chain_id: U64 + state: State + block_gas_limit: Uint + block_hashes: List[Hash32] + coinbase: Address + number: Uint + base_fee_per_gas: Uint + time: U256 + prev_randao: Bytes32 + excess_blob_gas: U64 + parent_beacon_block_root: Hash32 + + +@dataclass +class BlockOutput: + """ + Output from applying the block body to the present state. + + Contains the following: + + block_gas_used : `ethereum.base_types.Uint` + Gas used for executing all transactions. + transactions_trie : `ethereum.fork_types.Root` + Trie of all the transactions in the block. + receipts_trie : `ethereum.fork_types.Root` + Trie root of all the receipts in the block. + receipt_keys : + Key of all the receipts in the block. + block_logs : `Bloom` + Logs bloom of all the logs included in all the transactions of the + block. + withdrawals_trie : `ethereum.fork_types.Root` + Trie root of all the withdrawals in the block. + blob_gas_used : `ethereum.base_types.U64` + Total blob gas used in the block. + requests : `Bytes` + Hash of all the requests in the block. + """ + + block_gas_used: Uint = Uint(0) + transactions_trie: Trie[Bytes, Optional[Bytes | LegacyTransaction]] = ( + field(default_factory=lambda: Trie(secured=False, default=None)) + ) + receipts_trie: Trie[Bytes, Optional[Bytes | Receipt]] = field( + default_factory=lambda: Trie(secured=False, default=None) + ) + receipt_keys: Tuple[Bytes, ...] = field(default_factory=tuple) + block_logs: Tuple[Log, ...] = field(default_factory=tuple) + withdrawals_trie: Trie[Bytes, Optional[Bytes | Withdrawal]] = field( + default_factory=lambda: Trie(secured=False, default=None) + ) + blob_gas_used: U64 = U64(0) + requests: List[Bytes] = field(default_factory=list) + + +@dataclass +class TransactionEnvironment: + """ + Items that are used by contract creation or message call. + """ + + origin: Address + gas_price: Uint + gas: Uint + access_list_addresses: Set[Address] + access_list_storage_keys: Set[Tuple[Address, Bytes32]] + transient_storage: TransientStorage + blob_versioned_hashes: Tuple[VersionedHash, ...] + authorizations: Tuple[Authorization, ...] + index_in_block: Optional[Uint] + tx_hash: Optional[Hash32] + + +@dataclass +class Message: + """ + Items that are used by contract creation or message call. + """ + + block_env: BlockEnvironment + tx_env: TransactionEnvironment + caller: Address + target: Bytes0 | Address + current_target: Address + gas: Uint + value: U256 + data: Bytes + code_address: Optional[Address] + code: Bytes + depth: Uint + should_transfer_value: bool + is_static: bool + accessed_addresses: Set[Address] + accessed_storage_keys: Set[Tuple[Address, Bytes32]] + disable_precompiles: bool + parent_evm: Optional["Evm"] + + +@dataclass +class Evm: + """The internal state of the virtual machine.""" + + pc: Uint + stack: List[U256] + memory: bytearray + code: Bytes + gas_left: Uint + valid_jump_destinations: Set[Uint] + logs: Tuple[Log, ...] + refund_counter: int + running: bool + message: Message + output: Bytes + accounts_to_delete: Set[Address] + return_data: Bytes + error: Optional[EthereumException] + accessed_addresses: Set[Address] + accessed_storage_keys: Set[Tuple[Address, Bytes32]] + + +def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: + """ + Incorporate the state of a successful `child_evm` into the parent `evm`. + + Parameters + ---------- + evm : + The parent `EVM`. + child_evm : + The child evm to incorporate. + + """ + evm.gas_left += child_evm.gas_left + evm.logs += child_evm.logs + evm.refund_counter += child_evm.refund_counter + evm.accounts_to_delete.update(child_evm.accounts_to_delete) + evm.accessed_addresses.update(child_evm.accessed_addresses) + evm.accessed_storage_keys.update(child_evm.accessed_storage_keys) + + +def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: + """ + Incorporate the state of an unsuccessful `child_evm` into the parent `evm`. + + Parameters + ---------- + evm : + The parent `EVM`. + child_evm : + The child evm to incorporate. + + """ + evm.gas_left += child_evm.gas_left diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py new file mode 100644 index 0000000000..29909b5fa5 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -0,0 +1,211 @@ +""" +Set EOA account code. +""" + +from typing import Optional, Tuple + +from ethereum_rlp import rlp +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.crypto.elliptic_curve import SECP256K1N, secp256k1_recover +from ethereum.crypto.hash import keccak256 +from ethereum.exceptions import InvalidBlock, InvalidSignatureError + +from ..fork_types import Address, Authorization +from ..state import account_exists, get_account, increment_nonce, set_code +from ..utils.hexadecimal import hex_to_address +from ..vm.gas import GAS_COLD_ACCOUNT_ACCESS, GAS_WARM_ACCESS +from . import Evm, Message + +SET_CODE_TX_MAGIC = b"\x05" +EOA_DELEGATION_MARKER = b"\xef\x01\x00" +EOA_DELEGATION_MARKER_LENGTH = len(EOA_DELEGATION_MARKER) +EOA_DELEGATED_CODE_LENGTH = 23 +PER_EMPTY_ACCOUNT_COST = 25000 +PER_AUTH_BASE_COST = 12500 +NULL_ADDRESS = hex_to_address("0x0000000000000000000000000000000000000000") + + +def is_valid_delegation(code: bytes) -> bool: + """ + Whether the code is a valid delegation designation. + + Parameters + ---------- + code: `bytes` + The code to check. + + Returns + ------- + valid : `bool` + True if the code is a valid delegation designation, + False otherwise. + + """ + if ( + len(code) == EOA_DELEGATED_CODE_LENGTH + and code[:EOA_DELEGATION_MARKER_LENGTH] == EOA_DELEGATION_MARKER + ): + return True + return False + + +def get_delegated_code_address(code: bytes) -> Optional[Address]: + """ + Get the address to which the code delegates. + + Parameters + ---------- + code: `bytes` + The code to get the address from. + + Returns + ------- + address : `Optional[Address]` + The address of the delegated code. + + """ + if is_valid_delegation(code): + return Address(code[EOA_DELEGATION_MARKER_LENGTH:]) + return None + + +def recover_authority(authorization: Authorization) -> Address: + """ + Recover the authority address from the authorization. + + Parameters + ---------- + authorization + The authorization to recover the authority from. + + Raises + ------ + InvalidSignatureError + If the signature is invalid. + + Returns + ------- + authority : `Address` + The recovered authority address. + + """ + y_parity, r, s = authorization.y_parity, authorization.r, authorization.s + if y_parity not in (0, 1): + raise InvalidSignatureError("Invalid y_parity in authorization") + if U256(0) >= r or r >= SECP256K1N: + raise InvalidSignatureError("Invalid r value in authorization") + if U256(0) >= s or s > SECP256K1N // U256(2): + raise InvalidSignatureError("Invalid s value in authorization") + + signing_hash = keccak256( + SET_CODE_TX_MAGIC + + rlp.encode( + ( + authorization.chain_id, + authorization.address, + authorization.nonce, + ) + ) + ) + + public_key = secp256k1_recover(r, s, U256(y_parity), signing_hash) + return Address(keccak256(public_key)[12:32]) + + +def access_delegation( + evm: Evm, address: Address +) -> Tuple[bool, Address, Bytes, Uint]: + """ + Get the delegation address, code, and the cost of access from the address. + + Parameters + ---------- + evm : `Evm` + The execution frame. + address : `Address` + The address to get the delegation from. + + Returns + ------- + delegation : `Tuple[bool, Address, Bytes, Uint]` + The delegation address, code, and access gas cost. + + """ + state = evm.message.block_env.state + code = get_account(state, address).code + if not is_valid_delegation(code): + return False, address, code, Uint(0) + + address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) + if address in evm.accessed_addresses: + access_gas_cost = GAS_WARM_ACCESS + else: + evm.accessed_addresses.add(address) + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + code = get_account(state, address).code + + return True, address, code, access_gas_cost + + +def set_delegation(message: Message) -> U256: + """ + Set the delegation code for the authorities in the message. + + Parameters + ---------- + message : + Transaction specific items. + env : + External items required for EVM execution. + + Returns + ------- + refund_counter: `U256` + Refund from authority which already exists in state. + + """ + state = message.block_env.state + refund_counter = U256(0) + for auth in message.tx_env.authorizations: + if auth.chain_id not in (message.block_env.chain_id, U256(0)): + continue + + if auth.nonce >= U64.MAX_VALUE: + continue + + try: + authority = recover_authority(auth) + except InvalidSignatureError: + continue + + message.accessed_addresses.add(authority) + + authority_account = get_account(state, authority) + authority_code = authority_account.code + + if authority_code and not is_valid_delegation(authority_code): + continue + + authority_nonce = authority_account.nonce + if authority_nonce != auth.nonce: + continue + + if account_exists(state, authority): + refund_counter += U256(PER_EMPTY_ACCOUNT_COST - PER_AUTH_BASE_COST) + + if auth.address == NULL_ADDRESS: + code_to_set = b"" + else: + code_to_set = EOA_DELEGATION_MARKER + auth.address + set_code(state, authority, code_to_set) + + increment_nonce(state, authority) + + if message.code_address is None: + raise InvalidBlock("Invalid type 4 transaction: no target") + + message.code = get_account(state, message.code_address).code + + return refund_counter diff --git a/src/ethereum/forks/amsterdam/vm/exceptions.py b/src/ethereum/forks/amsterdam/vm/exceptions.py new file mode 100644 index 0000000000..4bf3cee405 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/exceptions.py @@ -0,0 +1,139 @@ +""" +Ethereum Virtual Machine (EVM) Exceptions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Exceptions which cause the EVM to halt exceptionally. +""" + +from ethereum.exceptions import EthereumException + + +class ExceptionalHalt(EthereumException): + """ + Indicates that the EVM has experienced an exceptional halt. This causes + execution to immediately end with all gas being consumed. + """ + + +class Revert(EthereumException): + """ + Raised by the `REVERT` opcode. + + Unlike other EVM exceptions this does not result in the consumption of all + gas. + """ + + pass + + +class StackUnderflowError(ExceptionalHalt): + """ + Occurs when a pop is executed on an empty stack. + """ + + pass + + +class StackOverflowError(ExceptionalHalt): + """ + Occurs when a push is executed on a stack at max capacity. + """ + + pass + + +class OutOfGasError(ExceptionalHalt): + """ + Occurs when an operation costs more than the amount of gas left in the + frame. + """ + + pass + + +class InvalidOpcode(ExceptionalHalt): + """ + Raised when an invalid opcode is encountered. + """ + + code: int + + def __init__(self, code: int) -> None: + super().__init__(code) + self.code = code + + +class InvalidJumpDestError(ExceptionalHalt): + """ + Occurs when the destination of a jump operation doesn't meet any of the + following criteria. + + * The jump destination is less than the length of the code. + * The jump destination should have the `JUMPDEST` opcode (0x5B). + * The jump destination shouldn't be part of the data corresponding to + `PUSH-N` opcodes. + """ + + +class StackDepthLimitError(ExceptionalHalt): + """ + Raised when the message depth is greater than `1024`. + """ + + pass + + +class WriteInStaticContext(ExceptionalHalt): + """ + Raised when an attempt is made to modify the state while operating inside + of a STATICCALL context. + """ + + pass + + +class OutOfBoundsRead(ExceptionalHalt): + """ + Raised when an attempt was made to read data beyond the + boundaries of the buffer. + """ + + pass + + +class InvalidParameter(ExceptionalHalt): + """ + Raised when invalid parameters are passed. + """ + + pass + + +class InvalidContractPrefix(ExceptionalHalt): + """ + Raised when the new contract code starts with 0xEF. + """ + + pass + + +class AddressCollision(ExceptionalHalt): + """ + Raised when the new contract address has a collision. + """ + + pass + + +class KZGProofError(ExceptionalHalt): + """ + Raised when the point evaluation precompile can't verify a proof. + """ + + pass diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py new file mode 100644 index 0000000000..62118f4c6a --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -0,0 +1,396 @@ +""" +Ethereum Virtual Machine (EVM) Gas. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +EVM gas constants and calculators. +""" + +from dataclasses import dataclass +from typing import List, Tuple + +from ethereum_types.numeric import U64, U256, Uint + +from ethereum.trace import GasAndRefund, evm_trace +from ethereum.utils.numeric import ceil32, taylor_exponential + +from ..blocks import Header +from ..transactions import BlobTransaction, Transaction +from . import Evm +from .exceptions import OutOfGasError + +GAS_JUMPDEST = Uint(1) +GAS_BASE = Uint(2) +GAS_VERY_LOW = Uint(3) +GAS_STORAGE_SET = Uint(20000) +GAS_STORAGE_UPDATE = Uint(5000) +GAS_STORAGE_CLEAR_REFUND = Uint(4800) +GAS_LOW = Uint(5) +GAS_MID = Uint(8) +GAS_HIGH = Uint(10) +GAS_EXPONENTIATION = Uint(10) +GAS_EXPONENTIATION_PER_BYTE = Uint(50) +GAS_MEMORY = Uint(3) +GAS_KECCAK256 = Uint(30) +GAS_KECCAK256_WORD = Uint(6) +GAS_COPY = Uint(3) +GAS_BLOCK_HASH = Uint(20) +GAS_LOG = Uint(375) +GAS_LOG_DATA = Uint(8) +GAS_LOG_TOPIC = Uint(375) +GAS_CREATE = Uint(32000) +GAS_CODE_DEPOSIT = Uint(200) +GAS_ZERO = Uint(0) +GAS_NEW_ACCOUNT = Uint(25000) +GAS_CALL_VALUE = Uint(9000) +GAS_CALL_STIPEND = Uint(2300) +GAS_SELF_DESTRUCT = Uint(5000) +GAS_SELF_DESTRUCT_NEW_ACCOUNT = Uint(25000) +GAS_ECRECOVER = Uint(3000) +GAS_P256VERIFY = Uint(6900) +GAS_SHA256 = Uint(60) +GAS_SHA256_WORD = Uint(12) +GAS_RIPEMD160 = Uint(600) +GAS_RIPEMD160_WORD = Uint(120) +GAS_IDENTITY = Uint(15) +GAS_IDENTITY_WORD = Uint(3) +GAS_RETURN_DATA_COPY = Uint(3) +GAS_FAST_STEP = Uint(5) +GAS_BLAKE2_PER_ROUND = Uint(1) +GAS_COLD_SLOAD = Uint(2100) +GAS_COLD_ACCOUNT_ACCESS = Uint(2600) +GAS_WARM_ACCESS = Uint(100) +GAS_INIT_CODE_WORD_COST = Uint(2) +GAS_BLOBHASH_OPCODE = Uint(3) +GAS_POINT_EVALUATION = Uint(50000) + +GAS_PER_BLOB = U64(2**17) +BLOB_SCHEDULE_TARGET = U64(6) +TARGET_BLOB_GAS_PER_BLOCK = GAS_PER_BLOB * BLOB_SCHEDULE_TARGET +BLOB_BASE_COST = Uint(2**13) +BLOB_SCHEDULE_MAX = U64(9) +MIN_BLOB_GASPRICE = Uint(1) +BLOB_BASE_FEE_UPDATE_FRACTION = Uint(5007716) + +GAS_BLS_G1_ADD = Uint(375) +GAS_BLS_G1_MUL = Uint(12000) +GAS_BLS_G1_MAP = Uint(5500) +GAS_BLS_G2_ADD = Uint(600) +GAS_BLS_G2_MUL = Uint(22500) +GAS_BLS_G2_MAP = Uint(23800) + + +@dataclass +class ExtendMemory: + """ + Define the parameters for memory extension in opcodes. + + `cost`: `ethereum.base_types.Uint` + The gas required to perform the extension + `expand_by`: `ethereum.base_types.Uint` + The size by which the memory will be extended + """ + + cost: Uint + expand_by: Uint + + +@dataclass +class MessageCallGas: + """ + Define the gas cost and gas given to the sub-call for + executing the call opcodes. + + `cost`: `ethereum.base_types.Uint` + The gas required to execute the call opcode, excludes + memory expansion costs. + `sub_call`: `ethereum.base_types.Uint` + The portion of gas available to sub-calls that is refundable + if not consumed. + """ + + cost: Uint + sub_call: Uint + + +def charge_gas(evm: Evm, amount: Uint) -> None: + """ + Subtracts `amount` from `evm.gas_left`. + + Parameters + ---------- + evm : + The current EVM. + amount : + The amount of gas the current operation requires. + + """ + evm_trace(evm, GasAndRefund(int(amount))) + + if evm.gas_left < amount: + raise OutOfGasError + else: + evm.gas_left -= amount + + +def calculate_memory_gas_cost(size_in_bytes: Uint) -> Uint: + """ + Calculates the gas cost for allocating memory + to the smallest multiple of 32 bytes, + such that the allocated size is at least as big as the given size. + + Parameters + ---------- + size_in_bytes : + The size of the data in bytes. + + Returns + ------- + total_gas_cost : `ethereum.base_types.Uint` + The gas cost for storing data in memory. + + """ + size_in_words = ceil32(size_in_bytes) // Uint(32) + linear_cost = size_in_words * GAS_MEMORY + quadratic_cost = size_in_words ** Uint(2) // Uint(512) + total_gas_cost = linear_cost + quadratic_cost + try: + return total_gas_cost + except ValueError as e: + raise OutOfGasError from e + + +def calculate_gas_extend_memory( + memory: bytearray, extensions: List[Tuple[U256, U256]] +) -> ExtendMemory: + """ + Calculates the gas amount to extend memory. + + Parameters + ---------- + memory : + Memory contents of the EVM. + extensions: + List of extensions to be made to the memory. + Consists of a tuple of start position and size. + + Returns + ------- + extend_memory: `ExtendMemory` + + """ + size_to_extend = Uint(0) + to_be_paid = Uint(0) + current_size = Uint(len(memory)) + for start_position, size in extensions: + if size == 0: + continue + before_size = ceil32(current_size) + after_size = ceil32(Uint(start_position) + Uint(size)) + if after_size <= before_size: + continue + + size_to_extend += after_size - before_size + already_paid = calculate_memory_gas_cost(before_size) + total_cost = calculate_memory_gas_cost(after_size) + to_be_paid += total_cost - already_paid + + current_size = after_size + + return ExtendMemory(to_be_paid, size_to_extend) + + +def calculate_message_call_gas( + value: U256, + gas: Uint, + gas_left: Uint, + memory_cost: Uint, + extra_gas: Uint, + call_stipend: Uint = GAS_CALL_STIPEND, +) -> MessageCallGas: + """ + Calculates the MessageCallGas (cost and gas made available to the sub-call) + for executing call Opcodes. + + Parameters + ---------- + value: + The amount of `ETH` that needs to be transferred. + gas : + The amount of gas provided to the message-call. + gas_left : + The amount of gas left in the current frame. + memory_cost : + The amount needed to extend the memory in the current frame. + extra_gas : + The amount of gas needed for transferring value + creating a new + account inside a message call. + call_stipend : + The amount of stipend provided to a message call to execute code while + transferring value(ETH). + + Returns + ------- + message_call_gas: `MessageCallGas` + + """ + call_stipend = Uint(0) if value == 0 else call_stipend + if gas_left < extra_gas + memory_cost: + return MessageCallGas(gas + extra_gas, gas + call_stipend) + + gas = min(gas, max_message_call_gas(gas_left - memory_cost - extra_gas)) + + return MessageCallGas(gas + extra_gas, gas + call_stipend) + + +def max_message_call_gas(gas: Uint) -> Uint: + """ + Calculates the maximum gas that is allowed for making a message call. + + Parameters + ---------- + gas : + The amount of gas provided to the message-call. + + Returns + ------- + max_allowed_message_call_gas: `ethereum.base_types.Uint` + The maximum gas allowed for making the message-call. + + """ + return gas - (gas // Uint(64)) + + +def init_code_cost(init_code_length: Uint) -> Uint: + """ + Calculates the gas to be charged for the init code in CREATE* + opcodes as well as create transactions. + + Parameters + ---------- + init_code_length : + The length of the init code provided to the opcode + or a create transaction + + Returns + ------- + init_code_gas: `ethereum.base_types.Uint` + The gas to be charged for the init code. + + """ + return GAS_INIT_CODE_WORD_COST * ceil32(init_code_length) // Uint(32) + + +def calculate_excess_blob_gas(parent_header: Header) -> U64: + """ + Calculated the excess blob gas for the current block based + on the gas used in the parent block. + + Parameters + ---------- + parent_header : + The parent block of the current block. + + Returns + ------- + excess_blob_gas: `ethereum.base_types.U64` + The excess blob gas for the current block. + + """ + # At the fork block, these are defined as zero. + excess_blob_gas = U64(0) + blob_gas_used = U64(0) + base_fee_per_gas = Uint(0) + + if isinstance(parent_header, Header): + # After the fork block, read them from the parent header. + excess_blob_gas = parent_header.excess_blob_gas + blob_gas_used = parent_header.blob_gas_used + base_fee_per_gas = parent_header.base_fee_per_gas + + parent_blob_gas = excess_blob_gas + blob_gas_used + if parent_blob_gas < TARGET_BLOB_GAS_PER_BLOCK: + return U64(0) + + target_blob_gas_price = Uint(GAS_PER_BLOB) + target_blob_gas_price *= calculate_blob_gas_price(excess_blob_gas) + + base_blob_tx_price = BLOB_BASE_COST * base_fee_per_gas + if base_blob_tx_price > target_blob_gas_price: + blob_schedule_delta = BLOB_SCHEDULE_MAX - BLOB_SCHEDULE_TARGET + return ( + excess_blob_gas + + blob_gas_used * blob_schedule_delta // BLOB_SCHEDULE_MAX + ) + + return parent_blob_gas - TARGET_BLOB_GAS_PER_BLOCK + + +def calculate_total_blob_gas(tx: Transaction) -> U64: + """ + Calculate the total blob gas for a transaction. + + Parameters + ---------- + tx : + The transaction for which the blob gas is to be calculated. + + Returns + ------- + total_blob_gas: `ethereum.base_types.Uint` + The total blob gas for the transaction. + + """ + if isinstance(tx, BlobTransaction): + return GAS_PER_BLOB * U64(len(tx.blob_versioned_hashes)) + else: + return U64(0) + + +def calculate_blob_gas_price(excess_blob_gas: U64) -> Uint: + """ + Calculate the blob gasprice for a block. + + Parameters + ---------- + excess_blob_gas : + The excess blob gas for the block. + + Returns + ------- + blob_gasprice: `Uint` + The blob gasprice. + + """ + return taylor_exponential( + MIN_BLOB_GASPRICE, + Uint(excess_blob_gas), + BLOB_BASE_FEE_UPDATE_FRACTION, + ) + + +def calculate_data_fee(excess_blob_gas: U64, tx: Transaction) -> Uint: + """ + Calculate the blob data fee for a transaction. + + Parameters + ---------- + excess_blob_gas : + The excess_blob_gas for the execution. + tx : + The transaction for which the blob data fee is to be calculated. + + Returns + ------- + data_fee: `Uint` + The blob data fee. + + """ + return Uint(calculate_total_blob_gas(tx)) * calculate_blob_gas_price( + excess_blob_gas + ) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/__init__.py b/src/ethereum/forks/amsterdam/vm/instructions/__init__.py new file mode 100644 index 0000000000..0da72c8ea5 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/__init__.py @@ -0,0 +1,367 @@ +""" +EVM Instruction Encoding (Opcodes). + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Machine readable representations of EVM instructions, and a mapping to their +implementations. +""" + +import enum +from typing import Callable, Dict + +from . import arithmetic as arithmetic_instructions +from . import bitwise as bitwise_instructions +from . import block as block_instructions +from . import comparison as comparison_instructions +from . import control_flow as control_flow_instructions +from . import environment as environment_instructions +from . import keccak as keccak_instructions +from . import log as log_instructions +from . import memory as memory_instructions +from . import stack as stack_instructions +from . import storage as storage_instructions +from . import system as system_instructions + + +class Ops(enum.Enum): + """ + Enum for EVM Opcodes. + """ + + # Arithmetic Ops + ADD = 0x01 + MUL = 0x02 + SUB = 0x03 + DIV = 0x04 + SDIV = 0x05 + MOD = 0x06 + SMOD = 0x07 + ADDMOD = 0x08 + MULMOD = 0x09 + EXP = 0x0A + SIGNEXTEND = 0x0B + + # Comparison Ops + LT = 0x10 + GT = 0x11 + SLT = 0x12 + SGT = 0x13 + EQ = 0x14 + ISZERO = 0x15 + + # Bitwise Ops + AND = 0x16 + OR = 0x17 + XOR = 0x18 + NOT = 0x19 + BYTE = 0x1A + SHL = 0x1B + SHR = 0x1C + SAR = 0x1D + CLZ = 0x1E + + # Keccak Op + KECCAK = 0x20 + + # Environmental Ops + ADDRESS = 0x30 + BALANCE = 0x31 + ORIGIN = 0x32 + CALLER = 0x33 + CALLVALUE = 0x34 + CALLDATALOAD = 0x35 + CALLDATASIZE = 0x36 + CALLDATACOPY = 0x37 + CODESIZE = 0x38 + CODECOPY = 0x39 + GASPRICE = 0x3A + EXTCODESIZE = 0x3B + EXTCODECOPY = 0x3C + RETURNDATASIZE = 0x3D + RETURNDATACOPY = 0x3E + EXTCODEHASH = 0x3F + + # Block Ops + BLOCKHASH = 0x40 + COINBASE = 0x41 + TIMESTAMP = 0x42 + NUMBER = 0x43 + PREVRANDAO = 0x44 + GASLIMIT = 0x45 + CHAINID = 0x46 + SELFBALANCE = 0x47 + BASEFEE = 0x48 + BLOBHASH = 0x49 + BLOBBASEFEE = 0x4A + + # Control Flow Ops + STOP = 0x00 + JUMP = 0x56 + JUMPI = 0x57 + PC = 0x58 + GAS = 0x5A + JUMPDEST = 0x5B + + # Storage Ops + SLOAD = 0x54 + SSTORE = 0x55 + TLOAD = 0x5C + TSTORE = 0x5D + + # Pop Operation + POP = 0x50 + + # Push Operations + PUSH0 = 0x5F + PUSH1 = 0x60 + PUSH2 = 0x61 + PUSH3 = 0x62 + PUSH4 = 0x63 + PUSH5 = 0x64 + PUSH6 = 0x65 + PUSH7 = 0x66 + PUSH8 = 0x67 + PUSH9 = 0x68 + PUSH10 = 0x69 + PUSH11 = 0x6A + PUSH12 = 0x6B + PUSH13 = 0x6C + PUSH14 = 0x6D + PUSH15 = 0x6E + PUSH16 = 0x6F + PUSH17 = 0x70 + PUSH18 = 0x71 + PUSH19 = 0x72 + PUSH20 = 0x73 + PUSH21 = 0x74 + PUSH22 = 0x75 + PUSH23 = 0x76 + PUSH24 = 0x77 + PUSH25 = 0x78 + PUSH26 = 0x79 + PUSH27 = 0x7A + PUSH28 = 0x7B + PUSH29 = 0x7C + PUSH30 = 0x7D + PUSH31 = 0x7E + PUSH32 = 0x7F + + # Dup operations + DUP1 = 0x80 + DUP2 = 0x81 + DUP3 = 0x82 + DUP4 = 0x83 + DUP5 = 0x84 + DUP6 = 0x85 + DUP7 = 0x86 + DUP8 = 0x87 + DUP9 = 0x88 + DUP10 = 0x89 + DUP11 = 0x8A + DUP12 = 0x8B + DUP13 = 0x8C + DUP14 = 0x8D + DUP15 = 0x8E + DUP16 = 0x8F + + # Swap operations + SWAP1 = 0x90 + SWAP2 = 0x91 + SWAP3 = 0x92 + SWAP4 = 0x93 + SWAP5 = 0x94 + SWAP6 = 0x95 + SWAP7 = 0x96 + SWAP8 = 0x97 + SWAP9 = 0x98 + SWAP10 = 0x99 + SWAP11 = 0x9A + SWAP12 = 0x9B + SWAP13 = 0x9C + SWAP14 = 0x9D + SWAP15 = 0x9E + SWAP16 = 0x9F + + # Memory Operations + MLOAD = 0x51 + MSTORE = 0x52 + MSTORE8 = 0x53 + MSIZE = 0x59 + MCOPY = 0x5E + + # Log Operations + LOG0 = 0xA0 + LOG1 = 0xA1 + LOG2 = 0xA2 + LOG3 = 0xA3 + LOG4 = 0xA4 + + # System Operations + CREATE = 0xF0 + CALL = 0xF1 + CALLCODE = 0xF2 + RETURN = 0xF3 + DELEGATECALL = 0xF4 + CREATE2 = 0xF5 + STATICCALL = 0xFA + REVERT = 0xFD + SELFDESTRUCT = 0xFF + + +op_implementation: Dict[Ops, Callable] = { + Ops.STOP: control_flow_instructions.stop, + Ops.ADD: arithmetic_instructions.add, + Ops.MUL: arithmetic_instructions.mul, + Ops.SUB: arithmetic_instructions.sub, + Ops.DIV: arithmetic_instructions.div, + Ops.SDIV: arithmetic_instructions.sdiv, + Ops.MOD: arithmetic_instructions.mod, + Ops.SMOD: arithmetic_instructions.smod, + Ops.ADDMOD: arithmetic_instructions.addmod, + Ops.MULMOD: arithmetic_instructions.mulmod, + Ops.EXP: arithmetic_instructions.exp, + Ops.SIGNEXTEND: arithmetic_instructions.signextend, + Ops.LT: comparison_instructions.less_than, + Ops.GT: comparison_instructions.greater_than, + Ops.SLT: comparison_instructions.signed_less_than, + Ops.SGT: comparison_instructions.signed_greater_than, + Ops.EQ: comparison_instructions.equal, + Ops.ISZERO: comparison_instructions.is_zero, + Ops.AND: bitwise_instructions.bitwise_and, + Ops.OR: bitwise_instructions.bitwise_or, + Ops.XOR: bitwise_instructions.bitwise_xor, + Ops.NOT: bitwise_instructions.bitwise_not, + Ops.BYTE: bitwise_instructions.get_byte, + Ops.SHL: bitwise_instructions.bitwise_shl, + Ops.SHR: bitwise_instructions.bitwise_shr, + Ops.SAR: bitwise_instructions.bitwise_sar, + Ops.CLZ: bitwise_instructions.count_leading_zeros, + Ops.KECCAK: keccak_instructions.keccak, + Ops.SLOAD: storage_instructions.sload, + Ops.BLOCKHASH: block_instructions.block_hash, + Ops.COINBASE: block_instructions.coinbase, + Ops.TIMESTAMP: block_instructions.timestamp, + Ops.NUMBER: block_instructions.number, + Ops.PREVRANDAO: block_instructions.prev_randao, + Ops.GASLIMIT: block_instructions.gas_limit, + Ops.CHAINID: block_instructions.chain_id, + Ops.MLOAD: memory_instructions.mload, + Ops.MSTORE: memory_instructions.mstore, + Ops.MSTORE8: memory_instructions.mstore8, + Ops.MSIZE: memory_instructions.msize, + Ops.MCOPY: memory_instructions.mcopy, + Ops.ADDRESS: environment_instructions.address, + Ops.BALANCE: environment_instructions.balance, + Ops.ORIGIN: environment_instructions.origin, + Ops.CALLER: environment_instructions.caller, + Ops.CALLVALUE: environment_instructions.callvalue, + Ops.CALLDATALOAD: environment_instructions.calldataload, + Ops.CALLDATASIZE: environment_instructions.calldatasize, + Ops.CALLDATACOPY: environment_instructions.calldatacopy, + Ops.CODESIZE: environment_instructions.codesize, + Ops.CODECOPY: environment_instructions.codecopy, + Ops.GASPRICE: environment_instructions.gasprice, + Ops.EXTCODESIZE: environment_instructions.extcodesize, + Ops.EXTCODECOPY: environment_instructions.extcodecopy, + Ops.RETURNDATASIZE: environment_instructions.returndatasize, + Ops.RETURNDATACOPY: environment_instructions.returndatacopy, + Ops.EXTCODEHASH: environment_instructions.extcodehash, + Ops.SELFBALANCE: environment_instructions.self_balance, + Ops.BASEFEE: environment_instructions.base_fee, + Ops.BLOBHASH: environment_instructions.blob_hash, + Ops.BLOBBASEFEE: environment_instructions.blob_base_fee, + Ops.SSTORE: storage_instructions.sstore, + Ops.TLOAD: storage_instructions.tload, + Ops.TSTORE: storage_instructions.tstore, + Ops.JUMP: control_flow_instructions.jump, + Ops.JUMPI: control_flow_instructions.jumpi, + Ops.PC: control_flow_instructions.pc, + Ops.GAS: control_flow_instructions.gas_left, + Ops.JUMPDEST: control_flow_instructions.jumpdest, + Ops.POP: stack_instructions.pop, + Ops.PUSH0: stack_instructions.push0, + Ops.PUSH1: stack_instructions.push1, + Ops.PUSH2: stack_instructions.push2, + Ops.PUSH3: stack_instructions.push3, + Ops.PUSH4: stack_instructions.push4, + Ops.PUSH5: stack_instructions.push5, + Ops.PUSH6: stack_instructions.push6, + Ops.PUSH7: stack_instructions.push7, + Ops.PUSH8: stack_instructions.push8, + Ops.PUSH9: stack_instructions.push9, + Ops.PUSH10: stack_instructions.push10, + Ops.PUSH11: stack_instructions.push11, + Ops.PUSH12: stack_instructions.push12, + Ops.PUSH13: stack_instructions.push13, + Ops.PUSH14: stack_instructions.push14, + Ops.PUSH15: stack_instructions.push15, + Ops.PUSH16: stack_instructions.push16, + Ops.PUSH17: stack_instructions.push17, + Ops.PUSH18: stack_instructions.push18, + Ops.PUSH19: stack_instructions.push19, + Ops.PUSH20: stack_instructions.push20, + Ops.PUSH21: stack_instructions.push21, + Ops.PUSH22: stack_instructions.push22, + Ops.PUSH23: stack_instructions.push23, + Ops.PUSH24: stack_instructions.push24, + Ops.PUSH25: stack_instructions.push25, + Ops.PUSH26: stack_instructions.push26, + Ops.PUSH27: stack_instructions.push27, + Ops.PUSH28: stack_instructions.push28, + Ops.PUSH29: stack_instructions.push29, + Ops.PUSH30: stack_instructions.push30, + Ops.PUSH31: stack_instructions.push31, + Ops.PUSH32: stack_instructions.push32, + Ops.DUP1: stack_instructions.dup1, + Ops.DUP2: stack_instructions.dup2, + Ops.DUP3: stack_instructions.dup3, + Ops.DUP4: stack_instructions.dup4, + Ops.DUP5: stack_instructions.dup5, + Ops.DUP6: stack_instructions.dup6, + Ops.DUP7: stack_instructions.dup7, + Ops.DUP8: stack_instructions.dup8, + Ops.DUP9: stack_instructions.dup9, + Ops.DUP10: stack_instructions.dup10, + Ops.DUP11: stack_instructions.dup11, + Ops.DUP12: stack_instructions.dup12, + Ops.DUP13: stack_instructions.dup13, + Ops.DUP14: stack_instructions.dup14, + Ops.DUP15: stack_instructions.dup15, + Ops.DUP16: stack_instructions.dup16, + Ops.SWAP1: stack_instructions.swap1, + Ops.SWAP2: stack_instructions.swap2, + Ops.SWAP3: stack_instructions.swap3, + Ops.SWAP4: stack_instructions.swap4, + Ops.SWAP5: stack_instructions.swap5, + Ops.SWAP6: stack_instructions.swap6, + Ops.SWAP7: stack_instructions.swap7, + Ops.SWAP8: stack_instructions.swap8, + Ops.SWAP9: stack_instructions.swap9, + Ops.SWAP10: stack_instructions.swap10, + Ops.SWAP11: stack_instructions.swap11, + Ops.SWAP12: stack_instructions.swap12, + Ops.SWAP13: stack_instructions.swap13, + Ops.SWAP14: stack_instructions.swap14, + Ops.SWAP15: stack_instructions.swap15, + Ops.SWAP16: stack_instructions.swap16, + Ops.LOG0: log_instructions.log0, + Ops.LOG1: log_instructions.log1, + Ops.LOG2: log_instructions.log2, + Ops.LOG3: log_instructions.log3, + Ops.LOG4: log_instructions.log4, + Ops.CREATE: system_instructions.create, + Ops.RETURN: system_instructions.return_, + Ops.CALL: system_instructions.call, + Ops.CALLCODE: system_instructions.callcode, + Ops.DELEGATECALL: system_instructions.delegatecall, + Ops.SELFDESTRUCT: system_instructions.selfdestruct, + Ops.STATICCALL: system_instructions.staticcall, + Ops.REVERT: system_instructions.revert, + Ops.CREATE2: system_instructions.create2, +} diff --git a/src/ethereum/forks/amsterdam/vm/instructions/arithmetic.py b/src/ethereum/forks/amsterdam/vm/instructions/arithmetic.py new file mode 100644 index 0000000000..de5d6ab43d --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/arithmetic.py @@ -0,0 +1,373 @@ +""" +Ethereum Virtual Machine (EVM) Arithmetic Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM Arithmetic instructions. +""" + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U256, Uint + +from ethereum.utils.numeric import get_sign + +from .. import Evm +from ..gas import ( + GAS_EXPONENTIATION, + GAS_EXPONENTIATION_PER_BYTE, + GAS_LOW, + GAS_MID, + GAS_VERY_LOW, + charge_gas, +) +from ..stack import pop, push + + +def add(evm: Evm) -> None: + """ + Adds the top two elements of the stack together, and pushes the result back + on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack) + y = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + result = x.wrapping_add(y) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def sub(evm: Evm) -> None: + """ + Subtracts the top two elements of the stack, and pushes the result back + on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack) + y = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + result = x.wrapping_sub(y) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def mul(evm: Evm) -> None: + """ + Multiply the top two elements of the stack, and pushes the result back + on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack) + y = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_LOW) + + # OPERATION + result = x.wrapping_mul(y) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def div(evm: Evm) -> None: + """ + Integer division of the top two elements of the stack. Pushes the result + back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + dividend = pop(evm.stack) + divisor = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_LOW) + + # OPERATION + if divisor == 0: + quotient = U256(0) + else: + quotient = dividend // divisor + + push(evm.stack, quotient) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +U255_CEIL_VALUE = 2**255 + + +def sdiv(evm: Evm) -> None: + """ + Signed integer division of the top two elements of the stack. Pushes the + result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + dividend = pop(evm.stack).to_signed() + divisor = pop(evm.stack).to_signed() + + # GAS + charge_gas(evm, GAS_LOW) + + # OPERATION + if divisor == 0: + quotient = 0 + elif dividend == -U255_CEIL_VALUE and divisor == -1: + quotient = -U255_CEIL_VALUE + else: + sign = get_sign(dividend * divisor) + quotient = sign * (abs(dividend) // abs(divisor)) + + push(evm.stack, U256.from_signed(quotient)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def mod(evm: Evm) -> None: + """ + Modulo remainder of the top two elements of the stack. Pushes the result + back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack) + y = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_LOW) + + # OPERATION + if y == 0: + remainder = U256(0) + else: + remainder = x % y + + push(evm.stack, remainder) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def smod(evm: Evm) -> None: + """ + Signed modulo remainder of the top two elements of the stack. Pushes the + result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack).to_signed() + y = pop(evm.stack).to_signed() + + # GAS + charge_gas(evm, GAS_LOW) + + # OPERATION + if y == 0: + remainder = 0 + else: + remainder = get_sign(x) * (abs(x) % abs(y)) + + push(evm.stack, U256.from_signed(remainder)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def addmod(evm: Evm) -> None: + """ + Modulo addition of the top 2 elements with the 3rd element. Pushes the + result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = Uint(pop(evm.stack)) + y = Uint(pop(evm.stack)) + z = Uint(pop(evm.stack)) + + # GAS + charge_gas(evm, GAS_MID) + + # OPERATION + if z == 0: + result = U256(0) + else: + result = U256((x + y) % z) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def mulmod(evm: Evm) -> None: + """ + Modulo multiplication of the top 2 elements with the 3rd element. Pushes + the result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = Uint(pop(evm.stack)) + y = Uint(pop(evm.stack)) + z = Uint(pop(evm.stack)) + + # GAS + charge_gas(evm, GAS_MID) + + # OPERATION + if z == 0: + result = U256(0) + else: + result = U256((x * y) % z) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def exp(evm: Evm) -> None: + """ + Exponential operation of the top 2 elements. Pushes the result back on + the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + base = Uint(pop(evm.stack)) + exponent = Uint(pop(evm.stack)) + + # GAS + # This is equivalent to 1 + floor(log(y, 256)). But in python the log + # function is inaccurate leading to wrong results. + exponent_bits = exponent.bit_length() + exponent_bytes = (exponent_bits + Uint(7)) // Uint(8) + charge_gas( + evm, GAS_EXPONENTIATION + GAS_EXPONENTIATION_PER_BYTE * exponent_bytes + ) + + # OPERATION + result = U256(pow(base, exponent, Uint(U256.MAX_VALUE) + Uint(1))) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def signextend(evm: Evm) -> None: + """ + Sign extend operation. In other words, extend a signed number which + fits in N bytes to 32 bytes. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + byte_num = pop(evm.stack) + value = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_LOW) + + # OPERATION + if byte_num > U256(31): + # Can't extend any further + result = value + else: + # U256(0).to_be_bytes() gives b'' instead b'\x00'. + value_bytes = Bytes(value.to_be_bytes32()) + # Now among the obtained value bytes, consider only + # N `least significant bytes`, where N is `byte_num + 1`. + value_bytes = value_bytes[31 - int(byte_num) :] + sign_bit = value_bytes[0] >> 7 + if sign_bit == 0: + result = U256.from_be_bytes(value_bytes) + else: + num_bytes_prepend = U256(32) - (byte_num + U256(1)) + result = U256.from_be_bytes( + bytearray([0xFF] * num_bytes_prepend) + value_bytes + ) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/bitwise.py b/src/ethereum/forks/amsterdam/vm/instructions/bitwise.py new file mode 100644 index 0000000000..cc6fa2fbb2 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/bitwise.py @@ -0,0 +1,274 @@ +""" +Ethereum Virtual Machine (EVM) Bitwise Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM bitwise instructions. +""" + +from ethereum_types.numeric import U256, Uint + +from .. import Evm +from ..gas import GAS_LOW, GAS_VERY_LOW, charge_gas +from ..stack import pop, push + + +def bitwise_and(evm: Evm) -> None: + """ + Bitwise AND operation of the top 2 elements of the stack. Pushes the + result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack) + y = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + push(evm.stack, x & y) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def bitwise_or(evm: Evm) -> None: + """ + Bitwise OR operation of the top 2 elements of the stack. Pushes the + result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack) + y = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + push(evm.stack, x | y) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def bitwise_xor(evm: Evm) -> None: + """ + Bitwise XOR operation of the top 2 elements of the stack. Pushes the + result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack) + y = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + push(evm.stack, x ^ y) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def bitwise_not(evm: Evm) -> None: + """ + Bitwise NOT operation of the top element of the stack. Pushes the + result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + push(evm.stack, ~x) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def get_byte(evm: Evm) -> None: + """ + For a word (defined by next top element of the stack), retrieve the + Nth byte (0-indexed and defined by top element of stack) from the + left (most significant) to right (least significant). + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + byte_index = pop(evm.stack) + word = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + if byte_index >= U256(32): + result = U256(0) + else: + extra_bytes_to_right = U256(31) - byte_index + # Remove the extra bytes in the right + word = word >> (extra_bytes_to_right * U256(8)) + # Remove the extra bytes in the left + word = word & U256(0xFF) + result = word + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def bitwise_shl(evm: Evm) -> None: + """ + Logical shift left (SHL) operation of the top 2 elements of the stack. + Pushes the result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + shift = Uint(pop(evm.stack)) + value = Uint(pop(evm.stack)) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + if shift < Uint(256): + result = U256((value << shift) & Uint(U256.MAX_VALUE)) + else: + result = U256(0) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def bitwise_shr(evm: Evm) -> None: + """ + Logical shift right (SHR) operation of the top 2 elements of the stack. + Pushes the result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + shift = pop(evm.stack) + value = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + if shift < U256(256): + result = value >> shift + else: + result = U256(0) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def bitwise_sar(evm: Evm) -> None: + """ + Arithmetic shift right (SAR) operation of the top 2 elements of the stack. + Pushes the result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + shift = int(pop(evm.stack)) + signed_value = pop(evm.stack).to_signed() + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + if shift < 256: + result = U256.from_signed(signed_value >> shift) + elif signed_value >= 0: + result = U256(0) + else: + result = U256.MAX_VALUE + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def count_leading_zeros(evm: Evm) -> None: + """ + Count the number of leading zero bits in a 256-bit word. + + Pops one value from the stack and pushes the number of leading zero bits. + If the input is zero, pushes 256. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_LOW) + + # OPERATION + bit_length = U256(x.bit_length()) + result = U256(256) - bit_length + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/block.py b/src/ethereum/forks/amsterdam/vm/instructions/block.py new file mode 100644 index 0000000000..78783751dd --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/block.py @@ -0,0 +1,261 @@ +""" +Ethereum Virtual Machine (EVM) Block Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM block instructions. +""" + +from ethereum_types.numeric import U256, Uint + +from .. import Evm +from ..gas import GAS_BASE, GAS_BLOCK_HASH, charge_gas +from ..stack import pop, push + + +def block_hash(evm: Evm) -> None: + """ + Push the hash of one of the 256 most recent complete blocks onto the + stack. The block number to hash is present at the top of the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.StackUnderflowError` + If `len(stack)` is less than `1`. + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.OutOfGasError` + If `evm.gas_left` is less than `20`. + + """ + # STACK + block_number = Uint(pop(evm.stack)) + + # GAS + charge_gas(evm, GAS_BLOCK_HASH) + + # OPERATION + max_block_number = block_number + Uint(256) + current_block_number = evm.message.block_env.number + if ( + current_block_number <= block_number + or current_block_number > max_block_number + ): + # Default hash to 0, if the block of interest is not yet on the chain + # (including the block which has the current executing transaction), + # or if the block's age is more than 256. + current_block_hash = b"\x00" + else: + current_block_hash = evm.message.block_env.block_hashes[ + -(current_block_number - block_number) + ] + + push(evm.stack, U256.from_be_bytes(current_block_hash)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def coinbase(evm: Evm) -> None: + """ + Push the current block's beneficiary address (address of the block miner) + onto the stack. + + Here the current block refers to the block in which the currently + executing transaction/call resides. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.StackOverflowError` + If `len(stack)` is equal to `1024`. + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.OutOfGasError` + If `evm.gas_left` is less than `2`. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256.from_be_bytes(evm.message.block_env.coinbase)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def timestamp(evm: Evm) -> None: + """ + Push the current block's timestamp onto the stack. Here the timestamp + being referred is actually the unix timestamp in seconds. + + Here the current block refers to the block in which the currently + executing transaction/call resides. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.StackOverflowError` + If `len(stack)` is equal to `1024`. + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.OutOfGasError` + If `evm.gas_left` is less than `2`. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, evm.message.block_env.time) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def number(evm: Evm) -> None: + """ + Push the current block's number onto the stack. + + Here the current block refers to the block in which the currently + executing transaction/call resides. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.StackOverflowError` + If `len(stack)` is equal to `1024`. + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.OutOfGasError` + If `evm.gas_left` is less than `2`. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(evm.message.block_env.number)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def prev_randao(evm: Evm) -> None: + """ + Push the `prev_randao` value onto the stack. + + The `prev_randao` value is the random output of the beacon chain's + randomness oracle for the previous block. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.StackOverflowError` + If `len(stack)` is equal to `1024`. + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.OutOfGasError` + If `evm.gas_left` is less than `2`. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256.from_be_bytes(evm.message.block_env.prev_randao)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def gas_limit(evm: Evm) -> None: + """ + Push the current block's gas limit onto the stack. + + Here the current block refers to the block in which the currently + executing transaction/call resides. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.StackOverflowError` + If `len(stack)` is equal to `1024`. + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.OutOfGasError` + If `evm.gas_left` is less than `2`. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(evm.message.block_env.block_gas_limit)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def chain_id(evm: Evm) -> None: + """ + Push the chain id onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.StackOverflowError` + If `len(stack)` is equal to `1024`. + :py:class:`~ethereum.forks.amsterdam.vm.exceptions.OutOfGasError` + If `evm.gas_left` is less than `2`. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(evm.message.block_env.chain_id)) + + # PROGRAM COUNTER + evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/comparison.py b/src/ethereum/forks/amsterdam/vm/instructions/comparison.py new file mode 100644 index 0000000000..a6a3d99bc8 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/comparison.py @@ -0,0 +1,177 @@ +""" +Ethereum Virtual Machine (EVM) Comparison Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM Comparison instructions. +""" + +from ethereum_types.numeric import U256, Uint + +from .. import Evm +from ..gas import GAS_VERY_LOW, charge_gas +from ..stack import pop, push + + +def less_than(evm: Evm) -> None: + """ + Checks if the top element is less than the next top element. Pushes the + result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + left = pop(evm.stack) + right = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + result = U256(left < right) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def signed_less_than(evm: Evm) -> None: + """ + Signed less-than comparison. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + left = pop(evm.stack).to_signed() + right = pop(evm.stack).to_signed() + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + result = U256(left < right) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def greater_than(evm: Evm) -> None: + """ + Checks if the top element is greater than the next top element. Pushes + the result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + left = pop(evm.stack) + right = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + result = U256(left > right) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def signed_greater_than(evm: Evm) -> None: + """ + Signed greater-than comparison. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + left = pop(evm.stack).to_signed() + right = pop(evm.stack).to_signed() + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + result = U256(left > right) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def equal(evm: Evm) -> None: + """ + Checks if the top element is equal to the next top element. Pushes + the result back on the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + left = pop(evm.stack) + right = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + result = U256(left == right) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def is_zero(evm: Evm) -> None: + """ + Checks if the top element is equal to 0. Pushes the result back on the + stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + x = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + result = U256(x == 0) + + push(evm.stack, result) + + # PROGRAM COUNTER + evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/control_flow.py b/src/ethereum/forks/amsterdam/vm/instructions/control_flow.py new file mode 100644 index 0000000000..b3b1f2316a --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/control_flow.py @@ -0,0 +1,171 @@ +""" +Ethereum Virtual Machine (EVM) Control Flow Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM control flow instructions. +""" + +from ethereum_types.numeric import U256, Uint + +from ...vm.gas import GAS_BASE, GAS_HIGH, GAS_JUMPDEST, GAS_MID, charge_gas +from .. import Evm +from ..exceptions import InvalidJumpDestError +from ..stack import pop, push + + +def stop(evm: Evm) -> None: + """ + Stop further execution of EVM code. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + pass + + # OPERATION + evm.running = False + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def jump(evm: Evm) -> None: + """ + Alter the program counter to the location specified by the top of the + stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + jump_dest = Uint(pop(evm.stack)) + + # GAS + charge_gas(evm, GAS_MID) + + # OPERATION + if jump_dest not in evm.valid_jump_destinations: + raise InvalidJumpDestError + + # PROGRAM COUNTER + evm.pc = Uint(jump_dest) + + +def jumpi(evm: Evm) -> None: + """ + Alter the program counter to the specified location if and only if a + condition is true. If the condition is not true, then the program counter + would increase only by 1. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + jump_dest = Uint(pop(evm.stack)) + conditional_value = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_HIGH) + + # OPERATION + if conditional_value == 0: + destination = evm.pc + Uint(1) + elif jump_dest not in evm.valid_jump_destinations: + raise InvalidJumpDestError + else: + destination = jump_dest + + # PROGRAM COUNTER + evm.pc = destination + + +def pc(evm: Evm) -> None: + """ + Push onto the stack the value of the program counter after reaching the + current instruction and without increasing it for the next instruction. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(evm.pc)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def gas_left(evm: Evm) -> None: + """ + Push the amount of available gas (including the corresponding reduction + for the cost of this instruction) onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(evm.gas_left)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def jumpdest(evm: Evm) -> None: + """ + Mark a valid destination for jumps. This is a noop, present only + to be used by `JUMP` and `JUMPI` opcodes to verify that their jump is + valid. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_JUMPDEST) + + # OPERATION + pass + + # PROGRAM COUNTER + evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py new file mode 100644 index 0000000000..8369043465 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -0,0 +1,600 @@ +""" +Ethereum Virtual Machine (EVM) Environmental Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM environment related instructions. +""" + +from ethereum_types.bytes import Bytes32 +from ethereum_types.numeric import U256, Uint, ulen + +from ethereum.crypto.hash import keccak256 +from ethereum.utils.numeric import ceil32 + +from ...fork_types import EMPTY_ACCOUNT +from ...state import get_account +from ...utils.address import to_address_masked +from ...vm.memory import buffer_read, memory_write +from .. import Evm +from ..exceptions import OutOfBoundsRead +from ..gas import ( + GAS_BASE, + GAS_BLOBHASH_OPCODE, + GAS_COLD_ACCOUNT_ACCESS, + GAS_COPY, + GAS_FAST_STEP, + GAS_RETURN_DATA_COPY, + GAS_VERY_LOW, + GAS_WARM_ACCESS, + calculate_blob_gas_price, + calculate_gas_extend_memory, + charge_gas, +) +from ..stack import pop, push + + +def address(evm: Evm) -> None: + """ + Pushes the address of the current executing account to the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256.from_be_bytes(evm.message.current_target)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def balance(evm: Evm) -> None: + """ + Pushes the balance of the given account onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + address = to_address_masked(pop(evm.stack)) + + # GAS + if address in evm.accessed_addresses: + charge_gas(evm, GAS_WARM_ACCESS) + else: + evm.accessed_addresses.add(address) + charge_gas(evm, GAS_COLD_ACCOUNT_ACCESS) + + # OPERATION + # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. + balance = get_account(evm.message.block_env.state, address).balance + + push(evm.stack, balance) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def origin(evm: Evm) -> None: + """ + Pushes the address of the original transaction sender to the stack. + The origin address can only be an EOA. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256.from_be_bytes(evm.message.tx_env.origin)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def caller(evm: Evm) -> None: + """ + Pushes the address of the caller onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256.from_be_bytes(evm.message.caller)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def callvalue(evm: Evm) -> None: + """ + Push the value (in wei) sent with the call onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, evm.message.value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def calldataload(evm: Evm) -> None: + """ + Push a word (32 bytes) of the input data belonging to the current + environment onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + start_index = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + value = buffer_read(evm.message.data, start_index, U256(32)) + + push(evm.stack, U256.from_be_bytes(value)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def calldatasize(evm: Evm) -> None: + """ + Push the size of input data in current environment onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(len(evm.message.data))) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def calldatacopy(evm: Evm) -> None: + """ + Copy a portion of the input data in current environment to memory. + + This will also expand the memory, in case that the memory is insufficient + to store the data. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + memory_start_index = pop(evm.stack) + data_start_index = pop(evm.stack) + size = pop(evm.stack) + + # GAS + words = ceil32(Uint(size)) // Uint(32) + copy_gas_cost = GAS_COPY * words + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_start_index, size)] + ) + charge_gas(evm, GAS_VERY_LOW + copy_gas_cost + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + value = buffer_read(evm.message.data, data_start_index, size) + memory_write(evm.memory, memory_start_index, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def codesize(evm: Evm) -> None: + """ + Push the size of code running in current environment onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(len(evm.code))) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def codecopy(evm: Evm) -> None: + """ + Copy a portion of the code in current environment to memory. + + This will also expand the memory, in case that the memory is insufficient + to store the data. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + memory_start_index = pop(evm.stack) + code_start_index = pop(evm.stack) + size = pop(evm.stack) + + # GAS + words = ceil32(Uint(size)) // Uint(32) + copy_gas_cost = GAS_COPY * words + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_start_index, size)] + ) + charge_gas(evm, GAS_VERY_LOW + copy_gas_cost + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + value = buffer_read(evm.code, code_start_index, size) + memory_write(evm.memory, memory_start_index, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def gasprice(evm: Evm) -> None: + """ + Push the gas price used in current environment onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(evm.message.tx_env.gas_price)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def extcodesize(evm: Evm) -> None: + """ + Push the code size of a given account onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + address = to_address_masked(pop(evm.stack)) + + # GAS + if address in evm.accessed_addresses: + access_gas_cost = GAS_WARM_ACCESS + else: + evm.accessed_addresses.add(address) + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + + charge_gas(evm, access_gas_cost) + + # OPERATION + code = get_account(evm.message.block_env.state, address).code + + codesize = U256(len(code)) + push(evm.stack, codesize) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def extcodecopy(evm: Evm) -> None: + """ + Copy a portion of an account's code to memory. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + address = to_address_masked(pop(evm.stack)) + memory_start_index = pop(evm.stack) + code_start_index = pop(evm.stack) + size = pop(evm.stack) + + # GAS + words = ceil32(Uint(size)) // Uint(32) + copy_gas_cost = GAS_COPY * words + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_start_index, size)] + ) + + if address in evm.accessed_addresses: + access_gas_cost = GAS_WARM_ACCESS + else: + evm.accessed_addresses.add(address) + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + + charge_gas(evm, access_gas_cost + copy_gas_cost + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + code = get_account(evm.message.block_env.state, address).code + + value = buffer_read(code, code_start_index, size) + memory_write(evm.memory, memory_start_index, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def returndatasize(evm: Evm) -> None: + """ + Pushes the size of the return data buffer onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(len(evm.return_data))) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def returndatacopy(evm: Evm) -> None: + """ + Copies data from the return data buffer code to memory. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + memory_start_index = pop(evm.stack) + return_data_start_position = pop(evm.stack) + size = pop(evm.stack) + + # GAS + words = ceil32(Uint(size)) // Uint(32) + copy_gas_cost = GAS_RETURN_DATA_COPY * words + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_start_index, size)] + ) + charge_gas(evm, GAS_VERY_LOW + copy_gas_cost + extend_memory.cost) + if Uint(return_data_start_position) + Uint(size) > ulen(evm.return_data): + raise OutOfBoundsRead + + evm.memory += b"\x00" * extend_memory.expand_by + value = evm.return_data[ + return_data_start_position : return_data_start_position + size + ] + memory_write(evm.memory, memory_start_index, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def extcodehash(evm: Evm) -> None: + """ + Returns the keccak256 hash of a contract’s bytecode. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + address = to_address_masked(pop(evm.stack)) + + # GAS + if address in evm.accessed_addresses: + access_gas_cost = GAS_WARM_ACCESS + else: + evm.accessed_addresses.add(address) + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + + charge_gas(evm, access_gas_cost) + + # OPERATION + account = get_account(evm.message.block_env.state, address) + + if account == EMPTY_ACCOUNT: + codehash = U256(0) + else: + code = account.code + codehash = U256.from_be_bytes(keccak256(code)) + + push(evm.stack, codehash) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def self_balance(evm: Evm) -> None: + """ + Pushes the balance of the current address to the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_FAST_STEP) + + # OPERATION + # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. + balance = get_account( + evm.message.block_env.state, evm.message.current_target + ).balance + + push(evm.stack, balance) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def base_fee(evm: Evm) -> None: + """ + Pushes the base fee of the current block on to the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(evm.message.block_env.base_fee_per_gas)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def blob_hash(evm: Evm) -> None: + """ + Pushes the versioned hash at a particular index on to the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + index = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_BLOBHASH_OPCODE) + + # OPERATION + if int(index) < len(evm.message.tx_env.blob_versioned_hashes): + blob_hash = evm.message.tx_env.blob_versioned_hashes[index] + else: + blob_hash = Bytes32(b"\x00" * 32) + push(evm.stack, U256.from_be_bytes(blob_hash)) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def blob_base_fee(evm: Evm) -> None: + """ + Pushes the blob base fee on to the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + blob_base_fee = calculate_blob_gas_price( + evm.message.block_env.excess_blob_gas + ) + push(evm.stack, U256(blob_base_fee)) + + # PROGRAM COUNTER + evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/keccak.py b/src/ethereum/forks/amsterdam/vm/instructions/keccak.py new file mode 100644 index 0000000000..44ba2eb40b --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/keccak.py @@ -0,0 +1,63 @@ +""" +Ethereum Virtual Machine (EVM) Keccak Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM keccak instructions. +""" + +from ethereum_types.numeric import U256, Uint + +from ethereum.crypto.hash import keccak256 +from ethereum.utils.numeric import ceil32 + +from .. import Evm +from ..gas import ( + GAS_KECCAK256, + GAS_KECCAK256_WORD, + calculate_gas_extend_memory, + charge_gas, +) +from ..memory import memory_read_bytes +from ..stack import pop, push + + +def keccak(evm: Evm) -> None: + """ + Pushes to the stack the Keccak-256 hash of a region of memory. + + This also expands the memory, in case the memory is insufficient to + access the data's memory location. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + memory_start_index = pop(evm.stack) + size = pop(evm.stack) + + # GAS + words = ceil32(Uint(size)) // Uint(32) + word_gas_cost = GAS_KECCAK256_WORD * words + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_start_index, size)] + ) + charge_gas(evm, GAS_KECCAK256 + word_gas_cost + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + data = memory_read_bytes(evm.memory, memory_start_index, size) + hashed = keccak256(data) + + push(evm.stack, U256.from_be_bytes(hashed)) + + # PROGRAM COUNTER + evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/log.py b/src/ethereum/forks/amsterdam/vm/instructions/log.py new file mode 100644 index 0000000000..a6e95b3170 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/log.py @@ -0,0 +1,88 @@ +""" +Ethereum Virtual Machine (EVM) Logging Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM logging instructions. +""" + +from functools import partial + +from ethereum_types.numeric import Uint + +from ...blocks import Log +from .. import Evm +from ..exceptions import WriteInStaticContext +from ..gas import ( + GAS_LOG, + GAS_LOG_DATA, + GAS_LOG_TOPIC, + calculate_gas_extend_memory, + charge_gas, +) +from ..memory import memory_read_bytes +from ..stack import pop + + +def log_n(evm: Evm, num_topics: int) -> None: + """ + Appends a log entry, having `num_topics` topics, to the evm logs. + + This will also expand the memory if the data (required by the log entry) + corresponding to the memory is not accessible. + + Parameters + ---------- + evm : + The current EVM frame. + num_topics : + The number of topics to be included in the log entry. + + """ + # STACK + memory_start_index = pop(evm.stack) + size = pop(evm.stack) + + topics = [] + for _ in range(num_topics): + topic = pop(evm.stack).to_be_bytes32() + topics.append(topic) + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_start_index, size)] + ) + charge_gas( + evm, + GAS_LOG + + GAS_LOG_DATA * Uint(size) + + GAS_LOG_TOPIC * Uint(num_topics) + + extend_memory.cost, + ) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + if evm.message.is_static: + raise WriteInStaticContext + log_entry = Log( + address=evm.message.current_target, + topics=tuple(topics), + data=memory_read_bytes(evm.memory, memory_start_index, size), + ) + + evm.logs = evm.logs + (log_entry,) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +log0 = partial(log_n, num_topics=0) +log1 = partial(log_n, num_topics=1) +log2 = partial(log_n, num_topics=2) +log3 = partial(log_n, num_topics=3) +log4 = partial(log_n, num_topics=4) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/memory.py b/src/ethereum/forks/amsterdam/vm/instructions/memory.py new file mode 100644 index 0000000000..631d33a7fd --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/memory.py @@ -0,0 +1,177 @@ +""" +Ethereum Virtual Machine (EVM) Memory Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM Memory instructions. +""" + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U256, Uint + +from ethereum.utils.numeric import ceil32 + +from .. import Evm +from ..gas import ( + GAS_BASE, + GAS_COPY, + GAS_VERY_LOW, + calculate_gas_extend_memory, + charge_gas, +) +from ..memory import memory_read_bytes, memory_write +from ..stack import pop, push + + +def mstore(evm: Evm) -> None: + """ + Stores a word to memory. + This also expands the memory, if the memory is + insufficient to store the word. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + start_position = pop(evm.stack) + value = pop(evm.stack).to_be_bytes32() + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, [(start_position, U256(len(value)))] + ) + + charge_gas(evm, GAS_VERY_LOW + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + memory_write(evm.memory, start_position, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def mstore8(evm: Evm) -> None: + """ + Stores a byte to memory. + This also expands the memory, if the memory is + insufficient to store the word. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + start_position = pop(evm.stack) + value = pop(evm.stack) + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, [(start_position, U256(1))] + ) + + charge_gas(evm, GAS_VERY_LOW + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + normalized_bytes_value = Bytes([value & U256(0xFF)]) + memory_write(evm.memory, start_position, normalized_bytes_value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def mload(evm: Evm) -> None: + """ + Load word from memory. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + start_position = pop(evm.stack) + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, [(start_position, U256(32))] + ) + charge_gas(evm, GAS_VERY_LOW + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + value = U256.from_be_bytes( + memory_read_bytes(evm.memory, start_position, U256(32)) + ) + push(evm.stack, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def msize(evm: Evm) -> None: + """ + Push the size of active memory in bytes onto the stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + push(evm.stack, U256(len(evm.memory))) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def mcopy(evm: Evm) -> None: + """ + Copy the bytes in memory from one location to another. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + destination = pop(evm.stack) + source = pop(evm.stack) + length = pop(evm.stack) + + # GAS + words = ceil32(Uint(length)) // Uint(32) + copy_gas_cost = GAS_COPY * words + + extend_memory = calculate_gas_extend_memory( + evm.memory, [(source, length), (destination, length)] + ) + charge_gas(evm, GAS_VERY_LOW + copy_gas_cost + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + value = memory_read_bytes(evm.memory, source, length) + memory_write(evm.memory, destination, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/stack.py b/src/ethereum/forks/amsterdam/vm/instructions/stack.py new file mode 100644 index 0000000000..e381b52c37 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/stack.py @@ -0,0 +1,208 @@ +""" +Ethereum Virtual Machine (EVM) Stack Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM stack related instructions. +""" + +from functools import partial + +from ethereum_types.numeric import U256, Uint + +from .. import Evm, stack +from ..exceptions import StackUnderflowError +from ..gas import GAS_BASE, GAS_VERY_LOW, charge_gas +from ..memory import buffer_read + + +def pop(evm: Evm) -> None: + """ + Remove item from stack. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + stack.pop(evm.stack) + + # GAS + charge_gas(evm, GAS_BASE) + + # OPERATION + pass + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def push_n(evm: Evm, num_bytes: int) -> None: + """ + Pushes a N-byte immediate onto the stack. Push zero if num_bytes is zero. + + Parameters + ---------- + evm : + The current EVM frame. + + num_bytes : + The number of immediate bytes to be read from the code and pushed to + the stack. Push zero if num_bytes is zero. + + """ + # STACK + pass + + # GAS + if num_bytes == 0: + charge_gas(evm, GAS_BASE) + else: + charge_gas(evm, GAS_VERY_LOW) + + # OPERATION + data_to_push = U256.from_be_bytes( + buffer_read(evm.code, U256(evm.pc + Uint(1)), U256(num_bytes)) + ) + stack.push(evm.stack, data_to_push) + + # PROGRAM COUNTER + evm.pc += Uint(1) + Uint(num_bytes) + + +def dup_n(evm: Evm, item_number: int) -> None: + """ + Duplicate the Nth stack item (from top of the stack) to the top of stack. + + Parameters + ---------- + evm : + The current EVM frame. + + item_number : + The stack item number (0-indexed from top of stack) to be duplicated + to the top of stack. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_VERY_LOW) + if item_number >= len(evm.stack): + raise StackUnderflowError + data_to_duplicate = evm.stack[len(evm.stack) - 1 - item_number] + stack.push(evm.stack, data_to_duplicate) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def swap_n(evm: Evm, item_number: int) -> None: + """ + Swap the top and the `item_number` element of the stack, where + the top of the stack is position zero. + + If `item_number` is zero, this function does nothing (which should not be + possible, since there is no `SWAP0` instruction). + + Parameters + ---------- + evm : + The current EVM frame. + + item_number : + The stack item number (0-indexed from top of stack) to be swapped + with the top of stack element. + + """ + # STACK + pass + + # GAS + charge_gas(evm, GAS_VERY_LOW) + if item_number >= len(evm.stack): + raise StackUnderflowError + evm.stack[-1], evm.stack[-1 - item_number] = ( + evm.stack[-1 - item_number], + evm.stack[-1], + ) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +push0 = partial(push_n, num_bytes=0) +push1 = partial(push_n, num_bytes=1) +push2 = partial(push_n, num_bytes=2) +push3 = partial(push_n, num_bytes=3) +push4 = partial(push_n, num_bytes=4) +push5 = partial(push_n, num_bytes=5) +push6 = partial(push_n, num_bytes=6) +push7 = partial(push_n, num_bytes=7) +push8 = partial(push_n, num_bytes=8) +push9 = partial(push_n, num_bytes=9) +push10 = partial(push_n, num_bytes=10) +push11 = partial(push_n, num_bytes=11) +push12 = partial(push_n, num_bytes=12) +push13 = partial(push_n, num_bytes=13) +push14 = partial(push_n, num_bytes=14) +push15 = partial(push_n, num_bytes=15) +push16 = partial(push_n, num_bytes=16) +push17 = partial(push_n, num_bytes=17) +push18 = partial(push_n, num_bytes=18) +push19 = partial(push_n, num_bytes=19) +push20 = partial(push_n, num_bytes=20) +push21 = partial(push_n, num_bytes=21) +push22 = partial(push_n, num_bytes=22) +push23 = partial(push_n, num_bytes=23) +push24 = partial(push_n, num_bytes=24) +push25 = partial(push_n, num_bytes=25) +push26 = partial(push_n, num_bytes=26) +push27 = partial(push_n, num_bytes=27) +push28 = partial(push_n, num_bytes=28) +push29 = partial(push_n, num_bytes=29) +push30 = partial(push_n, num_bytes=30) +push31 = partial(push_n, num_bytes=31) +push32 = partial(push_n, num_bytes=32) + +dup1 = partial(dup_n, item_number=0) +dup2 = partial(dup_n, item_number=1) +dup3 = partial(dup_n, item_number=2) +dup4 = partial(dup_n, item_number=3) +dup5 = partial(dup_n, item_number=4) +dup6 = partial(dup_n, item_number=5) +dup7 = partial(dup_n, item_number=6) +dup8 = partial(dup_n, item_number=7) +dup9 = partial(dup_n, item_number=8) +dup10 = partial(dup_n, item_number=9) +dup11 = partial(dup_n, item_number=10) +dup12 = partial(dup_n, item_number=11) +dup13 = partial(dup_n, item_number=12) +dup14 = partial(dup_n, item_number=13) +dup15 = partial(dup_n, item_number=14) +dup16 = partial(dup_n, item_number=15) + +swap1 = partial(swap_n, item_number=1) +swap2 = partial(swap_n, item_number=2) +swap3 = partial(swap_n, item_number=3) +swap4 = partial(swap_n, item_number=4) +swap5 = partial(swap_n, item_number=5) +swap6 = partial(swap_n, item_number=6) +swap7 = partial(swap_n, item_number=7) +swap8 = partial(swap_n, item_number=8) +swap9 = partial(swap_n, item_number=9) +swap10 = partial(swap_n, item_number=10) +swap11 = partial(swap_n, item_number=11) +swap12 = partial(swap_n, item_number=12) +swap13 = partial(swap_n, item_number=13) +swap14 = partial(swap_n, item_number=14) +swap15 = partial(swap_n, item_number=15) +swap16 = partial(swap_n, item_number=16) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py new file mode 100644 index 0000000000..e6777c30a0 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -0,0 +1,188 @@ +""" +Ethereum Virtual Machine (EVM) Storage Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM storage related instructions. +""" + +from ethereum_types.numeric import Uint + +from ...state import ( + get_storage, + get_storage_original, + get_transient_storage, + set_storage, + set_transient_storage, +) +from .. import Evm +from ..exceptions import OutOfGasError, WriteInStaticContext +from ..gas import ( + GAS_CALL_STIPEND, + GAS_COLD_SLOAD, + GAS_STORAGE_CLEAR_REFUND, + GAS_STORAGE_SET, + GAS_STORAGE_UPDATE, + GAS_WARM_ACCESS, + charge_gas, +) +from ..stack import pop, push + + +def sload(evm: Evm) -> None: + """ + Loads to the stack, the value corresponding to a certain key from the + storage of the current account. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + key = pop(evm.stack).to_be_bytes32() + + # GAS + if (evm.message.current_target, key) in evm.accessed_storage_keys: + charge_gas(evm, GAS_WARM_ACCESS) + else: + evm.accessed_storage_keys.add((evm.message.current_target, key)) + charge_gas(evm, GAS_COLD_SLOAD) + + # OPERATION + value = get_storage( + evm.message.block_env.state, evm.message.current_target, key + ) + + push(evm.stack, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def sstore(evm: Evm) -> None: + """ + Stores a value at a certain key in the current context's storage. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + key = pop(evm.stack).to_be_bytes32() + new_value = pop(evm.stack) + if evm.gas_left <= GAS_CALL_STIPEND: + raise OutOfGasError + + state = evm.message.block_env.state + original_value = get_storage_original( + state, evm.message.current_target, key + ) + current_value = get_storage(state, evm.message.current_target, key) + + gas_cost = Uint(0) + + if (evm.message.current_target, key) not in evm.accessed_storage_keys: + evm.accessed_storage_keys.add((evm.message.current_target, key)) + gas_cost += GAS_COLD_SLOAD + + if original_value == current_value and current_value != new_value: + if original_value == 0: + gas_cost += GAS_STORAGE_SET + else: + gas_cost += GAS_STORAGE_UPDATE - GAS_COLD_SLOAD + else: + gas_cost += GAS_WARM_ACCESS + + # Refund Counter Calculation + if current_value != new_value: + if original_value != 0 and current_value != 0 and new_value == 0: + # Storage is cleared for the first time in the transaction + evm.refund_counter += int(GAS_STORAGE_CLEAR_REFUND) + + if original_value != 0 and current_value == 0: + # Gas refund issued earlier to be reversed + evm.refund_counter -= int(GAS_STORAGE_CLEAR_REFUND) + + if original_value == new_value: + # Storage slot being restored to its original value + if original_value == 0: + # Slot was originally empty and was SET earlier + evm.refund_counter += int(GAS_STORAGE_SET - GAS_WARM_ACCESS) + else: + # Slot was originally non-empty and was UPDATED earlier + evm.refund_counter += int( + GAS_STORAGE_UPDATE - GAS_COLD_SLOAD - GAS_WARM_ACCESS + ) + + charge_gas(evm, gas_cost) + if evm.message.is_static: + raise WriteInStaticContext + set_storage(state, evm.message.current_target, key, new_value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def tload(evm: Evm) -> None: + """ + Loads to the stack, the value corresponding to a certain key from the + transient storage of the current account. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + key = pop(evm.stack).to_be_bytes32() + + # GAS + charge_gas(evm, GAS_WARM_ACCESS) + + # OPERATION + value = get_transient_storage( + evm.message.tx_env.transient_storage, evm.message.current_target, key + ) + push(evm.stack, value) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def tstore(evm: Evm) -> None: + """ + Stores a value at a certain key in the current context's transient storage. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + key = pop(evm.stack).to_be_bytes32() + new_value = pop(evm.stack) + + # GAS + charge_gas(evm, GAS_WARM_ACCESS) + if evm.message.is_static: + raise WriteInStaticContext + set_transient_storage( + evm.message.tx_env.transient_storage, + evm.message.current_target, + key, + new_value, + ) + + # PROGRAM COUNTER + evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py new file mode 100644 index 0000000000..fea7a0c1b9 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -0,0 +1,751 @@ +""" +Ethereum Virtual Machine (EVM) System Instructions. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementations of the EVM system related instructions. +""" + +from ethereum_types.bytes import Bytes, Bytes0 +from ethereum_types.numeric import U256, Uint + +from ethereum.utils.numeric import ceil32 + +from ...fork_types import Address +from ...state import ( + account_has_code_or_nonce, + account_has_storage, + get_account, + increment_nonce, + is_account_alive, + move_ether, + set_account_balance, +) +from ...utils.address import ( + compute_contract_address, + compute_create2_contract_address, + to_address_masked, +) +from ...vm.eoa_delegation import access_delegation +from .. import ( + Evm, + Message, + incorporate_child_on_error, + incorporate_child_on_success, +) +from ..exceptions import OutOfGasError, Revert, WriteInStaticContext +from ..gas import ( + GAS_CALL_VALUE, + GAS_COLD_ACCOUNT_ACCESS, + GAS_CREATE, + GAS_KECCAK256_WORD, + GAS_NEW_ACCOUNT, + GAS_SELF_DESTRUCT, + GAS_SELF_DESTRUCT_NEW_ACCOUNT, + GAS_WARM_ACCESS, + GAS_ZERO, + calculate_gas_extend_memory, + calculate_message_call_gas, + charge_gas, + init_code_cost, + max_message_call_gas, +) +from ..memory import memory_read_bytes, memory_write +from ..stack import pop, push + + +def generic_create( + evm: Evm, + endowment: U256, + contract_address: Address, + memory_start_position: U256, + memory_size: U256, +) -> None: + """ + Core logic used by the `CREATE*` family of opcodes. + """ + # This import causes a circular import error + # if it's not moved inside this method + from ...vm.interpreter import ( + MAX_INIT_CODE_SIZE, + STACK_DEPTH_LIMIT, + process_create_message, + ) + + call_data = memory_read_bytes( + evm.memory, memory_start_position, memory_size + ) + if len(call_data) > MAX_INIT_CODE_SIZE: + raise OutOfGasError + + create_message_gas = max_message_call_gas(Uint(evm.gas_left)) + evm.gas_left -= create_message_gas + if evm.message.is_static: + raise WriteInStaticContext + evm.return_data = b"" + + sender_address = evm.message.current_target + sender = get_account(evm.message.block_env.state, sender_address) + + if ( + sender.balance < endowment + or sender.nonce == Uint(2**64 - 1) + or evm.message.depth + Uint(1) > STACK_DEPTH_LIMIT + ): + evm.gas_left += create_message_gas + push(evm.stack, U256(0)) + return + + evm.accessed_addresses.add(contract_address) + + if account_has_code_or_nonce( + evm.message.block_env.state, contract_address + ) or account_has_storage(evm.message.block_env.state, contract_address): + increment_nonce( + evm.message.block_env.state, evm.message.current_target + ) + push(evm.stack, U256(0)) + return + + increment_nonce(evm.message.block_env.state, evm.message.current_target) + + child_message = Message( + block_env=evm.message.block_env, + tx_env=evm.message.tx_env, + caller=evm.message.current_target, + target=Bytes0(), + gas=create_message_gas, + value=endowment, + data=b"", + code=call_data, + current_target=contract_address, + depth=evm.message.depth + Uint(1), + code_address=None, + should_transfer_value=True, + is_static=False, + accessed_addresses=evm.accessed_addresses.copy(), + accessed_storage_keys=evm.accessed_storage_keys.copy(), + disable_precompiles=False, + parent_evm=evm, + ) + child_evm = process_create_message(child_message) + + if child_evm.error: + incorporate_child_on_error(evm, child_evm) + evm.return_data = child_evm.output + push(evm.stack, U256(0)) + else: + incorporate_child_on_success(evm, child_evm) + evm.return_data = b"" + push(evm.stack, U256.from_be_bytes(child_evm.message.current_target)) + + +def create(evm: Evm) -> None: + """ + Creates a new account with associated code. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + endowment = pop(evm.stack) + memory_start_position = pop(evm.stack) + memory_size = pop(evm.stack) + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_start_position, memory_size)] + ) + init_code_gas = init_code_cost(Uint(memory_size)) + + charge_gas(evm, GAS_CREATE + extend_memory.cost + init_code_gas) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + contract_address = compute_contract_address( + evm.message.current_target, + get_account( + evm.message.block_env.state, evm.message.current_target + ).nonce, + ) + + generic_create( + evm, + endowment, + contract_address, + memory_start_position, + memory_size, + ) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def create2(evm: Evm) -> None: + """ + Creates a new account with associated code. + + It's similar to CREATE opcode except that the address of new account + depends on the init_code instead of the nonce of sender. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + endowment = pop(evm.stack) + memory_start_position = pop(evm.stack) + memory_size = pop(evm.stack) + salt = pop(evm.stack).to_be_bytes32() + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_start_position, memory_size)] + ) + call_data_words = ceil32(Uint(memory_size)) // Uint(32) + init_code_gas = init_code_cost(Uint(memory_size)) + charge_gas( + evm, + GAS_CREATE + + GAS_KECCAK256_WORD * call_data_words + + extend_memory.cost + + init_code_gas, + ) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + contract_address = compute_create2_contract_address( + evm.message.current_target, + salt, + memory_read_bytes(evm.memory, memory_start_position, memory_size), + ) + + generic_create( + evm, + endowment, + contract_address, + memory_start_position, + memory_size, + ) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def return_(evm: Evm) -> None: + """ + Halts execution returning output data. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + memory_start_position = pop(evm.stack) + memory_size = pop(evm.stack) + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_start_position, memory_size)] + ) + + charge_gas(evm, GAS_ZERO + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + evm.output = memory_read_bytes( + evm.memory, memory_start_position, memory_size + ) + + evm.running = False + + # PROGRAM COUNTER + pass + + +def generic_call( + evm: Evm, + gas: Uint, + value: U256, + caller: Address, + to: Address, + code_address: Address, + should_transfer_value: bool, + is_staticcall: bool, + memory_input_start_position: U256, + memory_input_size: U256, + memory_output_start_position: U256, + memory_output_size: U256, + code: Bytes, + disable_precompiles: bool, +) -> None: + """ + Perform the core logic of the `CALL*` family of opcodes. + """ + from ...vm.interpreter import STACK_DEPTH_LIMIT, process_message + + evm.return_data = b"" + + if evm.message.depth + Uint(1) > STACK_DEPTH_LIMIT: + evm.gas_left += gas + push(evm.stack, U256(0)) + return + + call_data = memory_read_bytes( + evm.memory, memory_input_start_position, memory_input_size + ) + + child_message = Message( + block_env=evm.message.block_env, + tx_env=evm.message.tx_env, + caller=caller, + target=to, + gas=gas, + value=value, + data=call_data, + code=code, + current_target=to, + depth=evm.message.depth + Uint(1), + code_address=code_address, + should_transfer_value=should_transfer_value, + is_static=True if is_staticcall else evm.message.is_static, + accessed_addresses=evm.accessed_addresses.copy(), + accessed_storage_keys=evm.accessed_storage_keys.copy(), + disable_precompiles=disable_precompiles, + parent_evm=evm, + ) + child_evm = process_message(child_message) + + if child_evm.error: + incorporate_child_on_error(evm, child_evm) + evm.return_data = child_evm.output + push(evm.stack, U256(0)) + else: + incorporate_child_on_success(evm, child_evm) + evm.return_data = child_evm.output + push(evm.stack, U256(1)) + + actual_output_size = min(memory_output_size, U256(len(child_evm.output))) + memory_write( + evm.memory, + memory_output_start_position, + child_evm.output[:actual_output_size], + ) + + +def call(evm: Evm) -> None: + """ + Message-call into an account. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + gas = Uint(pop(evm.stack)) + to = to_address_masked(pop(evm.stack)) + value = pop(evm.stack) + memory_input_start_position = pop(evm.stack) + memory_input_size = pop(evm.stack) + memory_output_start_position = pop(evm.stack) + memory_output_size = pop(evm.stack) + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, + [ + (memory_input_start_position, memory_input_size), + (memory_output_start_position, memory_output_size), + ], + ) + + if to in evm.accessed_addresses: + access_gas_cost = GAS_WARM_ACCESS + else: + evm.accessed_addresses.add(to) + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + + code_address = to + ( + disable_precompiles, + code_address, + code, + delegated_access_gas_cost, + ) = access_delegation(evm, code_address) + access_gas_cost += delegated_access_gas_cost + + create_gas_cost = GAS_NEW_ACCOUNT + if value == 0 or is_account_alive(evm.message.block_env.state, to): + create_gas_cost = Uint(0) + transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE + message_call_gas = calculate_message_call_gas( + value, + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + create_gas_cost + transfer_gas_cost, + ) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) + if evm.message.is_static and value != U256(0): + raise WriteInStaticContext + evm.memory += b"\x00" * extend_memory.expand_by + sender_balance = get_account( + evm.message.block_env.state, evm.message.current_target + ).balance + if sender_balance < value: + push(evm.stack, U256(0)) + evm.return_data = b"" + evm.gas_left += message_call_gas.sub_call + else: + generic_call( + evm, + message_call_gas.sub_call, + value, + evm.message.current_target, + to, + code_address, + True, + False, + memory_input_start_position, + memory_input_size, + memory_output_start_position, + memory_output_size, + code, + disable_precompiles, + ) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def callcode(evm: Evm) -> None: + """ + Message-call into this account with alternative account’s code. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + gas = Uint(pop(evm.stack)) + code_address = to_address_masked(pop(evm.stack)) + value = pop(evm.stack) + memory_input_start_position = pop(evm.stack) + memory_input_size = pop(evm.stack) + memory_output_start_position = pop(evm.stack) + memory_output_size = pop(evm.stack) + + # GAS + to = evm.message.current_target + + extend_memory = calculate_gas_extend_memory( + evm.memory, + [ + (memory_input_start_position, memory_input_size), + (memory_output_start_position, memory_output_size), + ], + ) + + if code_address in evm.accessed_addresses: + access_gas_cost = GAS_WARM_ACCESS + else: + evm.accessed_addresses.add(code_address) + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + + ( + disable_precompiles, + code_address, + code, + delegated_access_gas_cost, + ) = access_delegation(evm, code_address) + access_gas_cost += delegated_access_gas_cost + + transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE + message_call_gas = calculate_message_call_gas( + value, + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + transfer_gas_cost, + ) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + sender_balance = get_account( + evm.message.block_env.state, evm.message.current_target + ).balance + if sender_balance < value: + push(evm.stack, U256(0)) + evm.return_data = b"" + evm.gas_left += message_call_gas.sub_call + else: + generic_call( + evm, + message_call_gas.sub_call, + value, + evm.message.current_target, + to, + code_address, + True, + False, + memory_input_start_position, + memory_input_size, + memory_output_start_position, + memory_output_size, + code, + disable_precompiles, + ) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def selfdestruct(evm: Evm) -> None: + """ + Halt execution and register account for later deletion. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + beneficiary = to_address_masked(pop(evm.stack)) + + # GAS + gas_cost = GAS_SELF_DESTRUCT + if beneficiary not in evm.accessed_addresses: + evm.accessed_addresses.add(beneficiary) + gas_cost += GAS_COLD_ACCOUNT_ACCESS + + if ( + not is_account_alive(evm.message.block_env.state, beneficiary) + and get_account( + evm.message.block_env.state, evm.message.current_target + ).balance + != 0 + ): + gas_cost += GAS_SELF_DESTRUCT_NEW_ACCOUNT + + charge_gas(evm, gas_cost) + if evm.message.is_static: + raise WriteInStaticContext + + originator = evm.message.current_target + originator_balance = get_account( + evm.message.block_env.state, originator + ).balance + + move_ether( + evm.message.block_env.state, + originator, + beneficiary, + originator_balance, + ) + + # register account for deletion only if it was created + # in the same transaction + if originator in evm.message.block_env.state.created_accounts: + # If beneficiary is the same as originator, then + # the ether is burnt. + set_account_balance(evm.message.block_env.state, originator, U256(0)) + evm.accounts_to_delete.add(originator) + + # HALT the execution + evm.running = False + + # PROGRAM COUNTER + pass + + +def delegatecall(evm: Evm) -> None: + """ + Message-call into an account. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + gas = Uint(pop(evm.stack)) + code_address = to_address_masked(pop(evm.stack)) + memory_input_start_position = pop(evm.stack) + memory_input_size = pop(evm.stack) + memory_output_start_position = pop(evm.stack) + memory_output_size = pop(evm.stack) + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, + [ + (memory_input_start_position, memory_input_size), + (memory_output_start_position, memory_output_size), + ], + ) + + if code_address in evm.accessed_addresses: + access_gas_cost = GAS_WARM_ACCESS + else: + evm.accessed_addresses.add(code_address) + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + + ( + disable_precompiles, + code_address, + code, + delegated_access_gas_cost, + ) = access_delegation(evm, code_address) + access_gas_cost += delegated_access_gas_cost + + message_call_gas = calculate_message_call_gas( + U256(0), gas, Uint(evm.gas_left), extend_memory.cost, access_gas_cost + ) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + generic_call( + evm, + message_call_gas.sub_call, + evm.message.value, + evm.message.caller, + evm.message.current_target, + code_address, + False, + False, + memory_input_start_position, + memory_input_size, + memory_output_start_position, + memory_output_size, + code, + disable_precompiles, + ) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def staticcall(evm: Evm) -> None: + """ + Message-call into an account. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + gas = Uint(pop(evm.stack)) + to = to_address_masked(pop(evm.stack)) + memory_input_start_position = pop(evm.stack) + memory_input_size = pop(evm.stack) + memory_output_start_position = pop(evm.stack) + memory_output_size = pop(evm.stack) + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, + [ + (memory_input_start_position, memory_input_size), + (memory_output_start_position, memory_output_size), + ], + ) + + if to in evm.accessed_addresses: + access_gas_cost = GAS_WARM_ACCESS + else: + evm.accessed_addresses.add(to) + access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + + code_address = to + ( + disable_precompiles, + code_address, + code, + delegated_access_gas_cost, + ) = access_delegation(evm, code_address) + access_gas_cost += delegated_access_gas_cost + + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost, + ) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + generic_call( + evm, + message_call_gas.sub_call, + U256(0), + evm.message.current_target, + to, + code_address, + True, + True, + memory_input_start_position, + memory_input_size, + memory_output_start_position, + memory_output_size, + code, + disable_precompiles, + ) + + # PROGRAM COUNTER + evm.pc += Uint(1) + + +def revert(evm: Evm) -> None: + """ + Stop execution and revert state changes, without consuming all provided gas + and also has the ability to return a reason. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + # STACK + memory_start_index = pop(evm.stack) + size = pop(evm.stack) + + # GAS + extend_memory = calculate_gas_extend_memory( + evm.memory, [(memory_start_index, size)] + ) + + charge_gas(evm, extend_memory.cost) + + # OPERATION + evm.memory += b"\x00" * extend_memory.expand_by + output = memory_read_bytes(evm.memory, memory_start_index, size) + evm.output = Bytes(output) + raise Revert + + # PROGRAM COUNTER + # no-op diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py new file mode 100644 index 0000000000..07e9f1d2db --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -0,0 +1,324 @@ +""" +Ethereum Virtual Machine (EVM) Interpreter. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +A straightforward interpreter that executes EVM code. +""" + +from dataclasses import dataclass +from typing import Optional, Set, Tuple + +from ethereum_types.bytes import Bytes, Bytes0 +from ethereum_types.numeric import U256, Uint, ulen + +from ethereum.exceptions import EthereumException +from ethereum.trace import ( + EvmStop, + OpEnd, + OpException, + OpStart, + PrecompileEnd, + PrecompileStart, + TransactionEnd, + evm_trace, +) + +from ..blocks import Log +from ..fork_types import Address +from ..state import ( + account_has_code_or_nonce, + account_has_storage, + begin_transaction, + commit_transaction, + destroy_storage, + get_account, + increment_nonce, + mark_account_created, + move_ether, + rollback_transaction, + set_code, +) +from ..vm import Message +from ..vm.eoa_delegation import get_delegated_code_address, set_delegation +from ..vm.gas import GAS_CODE_DEPOSIT, charge_gas +from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS +from . import Evm +from .exceptions import ( + AddressCollision, + ExceptionalHalt, + InvalidContractPrefix, + InvalidOpcode, + OutOfGasError, + Revert, + StackDepthLimitError, +) +from .instructions import Ops, op_implementation +from .runtime import get_valid_jump_destinations + +STACK_DEPTH_LIMIT = Uint(1024) +MAX_CODE_SIZE = 0x6000 +MAX_INIT_CODE_SIZE = 2 * MAX_CODE_SIZE + + +@dataclass +class MessageCallOutput: + """ + Output of a particular message call. + + Contains the following: + + 1. `gas_left`: remaining gas after execution. + 2. `refund_counter`: gas to refund after execution. + 3. `logs`: list of `Log` generated during execution. + 4. `accounts_to_delete`: Contracts which have self-destructed. + 5. `error`: The error from the execution if any. + 6. `return_data`: The output of the execution. + """ + + gas_left: Uint + refund_counter: U256 + logs: Tuple[Log, ...] + accounts_to_delete: Set[Address] + error: Optional[EthereumException] + return_data: Bytes + + +def process_message_call(message: Message) -> MessageCallOutput: + """ + If `message.target` is empty then it creates a smart contract + else it executes a call from the `message.caller` to the `message.target`. + + Parameters + ---------- + message : + Transaction specific items. + + Returns + ------- + output : `MessageCallOutput` + Output of the message call + + """ + block_env = message.block_env + refund_counter = U256(0) + if message.target == Bytes0(b""): + is_collision = account_has_code_or_nonce( + block_env.state, message.current_target + ) or account_has_storage(block_env.state, message.current_target) + if is_collision: + return MessageCallOutput( + Uint(0), + U256(0), + tuple(), + set(), + AddressCollision(), + Bytes(b""), + ) + else: + evm = process_create_message(message) + else: + if message.tx_env.authorizations != (): + refund_counter += set_delegation(message) + + delegated_address = get_delegated_code_address(message.code) + if delegated_address is not None: + message.disable_precompiles = True + message.accessed_addresses.add(delegated_address) + message.code = get_account(block_env.state, delegated_address).code + message.code_address = delegated_address + + evm = process_message(message) + + if evm.error: + logs: Tuple[Log, ...] = () + accounts_to_delete = set() + else: + logs = evm.logs + accounts_to_delete = evm.accounts_to_delete + refund_counter += U256(evm.refund_counter) + + tx_end = TransactionEnd( + int(message.gas) - int(evm.gas_left), evm.output, evm.error + ) + evm_trace(evm, tx_end) + + return MessageCallOutput( + gas_left=evm.gas_left, + refund_counter=refund_counter, + logs=logs, + accounts_to_delete=accounts_to_delete, + error=evm.error, + return_data=evm.output, + ) + + +def process_create_message(message: Message) -> Evm: + """ + Executes a call to create a smart contract. + + Parameters + ---------- + message : + Transaction specific items. + + Returns + ------- + evm: :py:class:`~ethereum.forks.amsterdam.vm.Evm` + Items containing execution specific objects. + + """ + state = message.block_env.state + transient_storage = message.tx_env.transient_storage + # take snapshot of state before processing the message + begin_transaction(state, transient_storage) + + # If the address where the account is being created has storage, it is + # destroyed. This can only happen in the following highly unlikely + # circumstances: + # * The address created by a `CREATE` call collides with a subsequent + # `CREATE` or `CREATE2` call. + # * The first `CREATE` happened before Spurious Dragon and left empty + # code. + destroy_storage(state, message.current_target) + + # In the previously mentioned edge case the preexisting storage is ignored + # for gas refund purposes. In order to do this we must track created + # accounts. This tracking is also needed to respect the constraints + # added to SELFDESTRUCT by EIP-6780. + mark_account_created(state, message.current_target) + + increment_nonce(state, message.current_target) + evm = process_message(message) + if not evm.error: + contract_code = evm.output + contract_code_gas = Uint(len(contract_code)) * GAS_CODE_DEPOSIT + try: + if len(contract_code) > 0: + if contract_code[0] == 0xEF: + raise InvalidContractPrefix + charge_gas(evm, contract_code_gas) + if len(contract_code) > MAX_CODE_SIZE: + raise OutOfGasError + except ExceptionalHalt as error: + rollback_transaction(state, transient_storage) + evm.gas_left = Uint(0) + evm.output = b"" + evm.error = error + else: + set_code(state, message.current_target, contract_code) + commit_transaction(state, transient_storage) + else: + rollback_transaction(state, transient_storage) + return evm + + +def process_message(message: Message) -> Evm: + """ + Move ether and execute the relevant code. + + Parameters + ---------- + message : + Transaction specific items. + + Returns + ------- + evm: :py:class:`~ethereum.forks.amsterdam.vm.Evm` + Items containing execution specific objects + + """ + state = message.block_env.state + transient_storage = message.tx_env.transient_storage + if message.depth > STACK_DEPTH_LIMIT: + raise StackDepthLimitError("Stack depth limit reached") + + # take snapshot of state before processing the message + begin_transaction(state, transient_storage) + + if message.should_transfer_value and message.value != 0: + move_ether( + state, message.caller, message.current_target, message.value + ) + + evm = execute_code(message) + if evm.error: + # revert state to the last saved checkpoint + # since the message call resulted in an error + rollback_transaction(state, transient_storage) + else: + commit_transaction(state, transient_storage) + return evm + + +def execute_code(message: Message) -> Evm: + """ + Executes bytecode present in the `message`. + + Parameters + ---------- + message : + Transaction specific items. + + Returns + ------- + evm: `ethereum.vm.EVM` + Items containing execution specific objects + + """ + code = message.code + valid_jump_destinations = get_valid_jump_destinations(code) + + evm = Evm( + pc=Uint(0), + stack=[], + memory=bytearray(), + code=code, + gas_left=message.gas, + valid_jump_destinations=valid_jump_destinations, + logs=(), + refund_counter=0, + running=True, + message=message, + output=b"", + accounts_to_delete=set(), + return_data=b"", + error=None, + accessed_addresses=message.accessed_addresses, + accessed_storage_keys=message.accessed_storage_keys, + ) + try: + if evm.message.code_address in PRE_COMPILED_CONTRACTS: + if message.disable_precompiles: + return evm + evm_trace(evm, PrecompileStart(evm.message.code_address)) + PRE_COMPILED_CONTRACTS[evm.message.code_address](evm) + evm_trace(evm, PrecompileEnd()) + return evm + + while evm.running and evm.pc < ulen(evm.code): + try: + op = Ops(evm.code[evm.pc]) + except ValueError as e: + raise InvalidOpcode(evm.code[evm.pc]) from e + + evm_trace(evm, OpStart(op)) + op_implementation[op](evm) + evm_trace(evm, OpEnd()) + + evm_trace(evm, EvmStop(Ops.STOP)) + + except ExceptionalHalt as error: + evm_trace(evm, OpException(error)) + evm.gas_left = Uint(0) + evm.output = b"" + evm.error = error + except Revert as error: + evm_trace(evm, OpException(error)) + evm.error = error + return evm diff --git a/src/ethereum/forks/amsterdam/vm/memory.py b/src/ethereum/forks/amsterdam/vm/memory.py new file mode 100644 index 0000000000..3b76b2454c --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/memory.py @@ -0,0 +1,83 @@ +""" +Ethereum Virtual Machine (EVM) Memory. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +EVM memory operations. +""" + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U256, Uint + +from ethereum.utils.byte import right_pad_zero_bytes + + +def memory_write( + memory: bytearray, start_position: U256, value: Bytes +) -> None: + """ + Writes to memory. + + Parameters + ---------- + memory : + Memory contents of the EVM. + start_position : + Starting pointer to the memory. + value : + Data to write to memory. + + """ + memory[start_position : int(start_position) + len(value)] = value + + +def memory_read_bytes( + memory: bytearray, start_position: U256, size: U256 +) -> Bytes: + """ + Read bytes from memory. + + Parameters + ---------- + memory : + Memory contents of the EVM. + start_position : + Starting pointer to the memory. + size : + Size of the data that needs to be read from `start_position`. + + Returns + ------- + data_bytes : + Data read from memory. + + """ + return Bytes(memory[start_position : Uint(start_position) + Uint(size)]) + + +def buffer_read(buffer: Bytes, start_position: U256, size: U256) -> Bytes: + """ + Read bytes from a buffer. Padding with zeros if necessary. + + Parameters + ---------- + buffer : + Memory contents of the EVM. + start_position : + Starting pointer to the memory. + size : + Size of the data that needs to be read from `start_position`. + + Returns + ------- + data_bytes : + Data read from memory. + + """ + buffer_slice = buffer[start_position : Uint(start_position) + Uint(size)] + return right_pad_zero_bytes(bytes(buffer_slice), size) diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/__init__.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/__init__.py new file mode 100644 index 0000000000..d32959fc93 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/__init__.py @@ -0,0 +1,55 @@ +""" +Precompiled Contract Addresses. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Addresses of precompiled contracts and mappings to their +implementations. +""" + +from ...utils.hexadecimal import hex_to_address + +__all__ = ( + "ECRECOVER_ADDRESS", + "SHA256_ADDRESS", + "RIPEMD160_ADDRESS", + "IDENTITY_ADDRESS", + "MODEXP_ADDRESS", + "ALT_BN128_ADD_ADDRESS", + "ALT_BN128_MUL_ADDRESS", + "ALT_BN128_PAIRING_CHECK_ADDRESS", + "BLAKE2F_ADDRESS", + "POINT_EVALUATION_ADDRESS", + "BLS12_G1_ADD_ADDRESS", + "BLS12_G1_MSM_ADDRESS", + "BLS12_G2_ADD_ADDRESS", + "BLS12_G2_MSM_ADDRESS", + "BLS12_PAIRING_ADDRESS", + "BLS12_MAP_FP_TO_G1_ADDRESS", + "BLS12_MAP_FP2_TO_G2_ADDRESS", + "P256VERIFY_ADDRESS", +) + +ECRECOVER_ADDRESS = hex_to_address("0x01") +SHA256_ADDRESS = hex_to_address("0x02") +RIPEMD160_ADDRESS = hex_to_address("0x03") +IDENTITY_ADDRESS = hex_to_address("0x04") +MODEXP_ADDRESS = hex_to_address("0x05") +ALT_BN128_ADD_ADDRESS = hex_to_address("0x06") +ALT_BN128_MUL_ADDRESS = hex_to_address("0x07") +ALT_BN128_PAIRING_CHECK_ADDRESS = hex_to_address("0x08") +BLAKE2F_ADDRESS = hex_to_address("0x09") +POINT_EVALUATION_ADDRESS = hex_to_address("0x0a") +BLS12_G1_ADD_ADDRESS = hex_to_address("0x0b") +BLS12_G1_MSM_ADDRESS = hex_to_address("0x0c") +BLS12_G2_ADD_ADDRESS = hex_to_address("0x0d") +BLS12_G2_MSM_ADDRESS = hex_to_address("0x0e") +BLS12_PAIRING_ADDRESS = hex_to_address("0x0f") +BLS12_MAP_FP_TO_G1_ADDRESS = hex_to_address("0x10") +BLS12_MAP_FP2_TO_G2_ADDRESS = hex_to_address("0x11") +P256VERIFY_ADDRESS = hex_to_address("0x100") diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/alt_bn128.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/alt_bn128.py new file mode 100644 index 0000000000..214725d8da --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/alt_bn128.py @@ -0,0 +1,230 @@ +""" +Ethereum Virtual Machine (EVM) ALT_BN128 CONTRACTS. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the ALT_BN128 precompiled contracts. +""" + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U256, Uint +from py_ecc.optimized_bn128.optimized_curve import ( + FQ, + FQ2, + FQ12, + add, + b, + b2, + curve_order, + field_modulus, + is_inf, + is_on_curve, + multiply, + normalize, +) +from py_ecc.optimized_bn128.optimized_pairing import pairing +from py_ecc.typing import Optimized_Point3D as Point3D + +from ...vm import Evm +from ...vm.gas import charge_gas +from ...vm.memory import buffer_read +from ..exceptions import InvalidParameter, OutOfGasError + + +def bytes_to_g1(data: Bytes) -> Point3D[FQ]: + """ + Decode 64 bytes to a point on the curve. + + Parameters + ---------- + data : + The bytes data to decode. + + Returns + ------- + point : Point3D + A point on the curve. + + Raises + ------ + InvalidParameter + Either a field element is invalid or the point is not on the curve. + + """ + if len(data) != 64: + raise InvalidParameter("Input should be 64 bytes long") + + x_bytes = buffer_read(data, U256(0), U256(32)) + x = int(U256.from_be_bytes(x_bytes)) + y_bytes = buffer_read(data, U256(32), U256(32)) + y = int(U256.from_be_bytes(y_bytes)) + + if x >= field_modulus: + raise InvalidParameter("Invalid field element") + if y >= field_modulus: + raise InvalidParameter("Invalid field element") + + z = 1 + if x == 0 and y == 0: + z = 0 + + point = (FQ(x), FQ(y), FQ(z)) + + # Check if the point is on the curve + if not is_on_curve(point, b): + raise InvalidParameter("Point is not on curve") + + return point + + +def bytes_to_g2(data: Bytes) -> Point3D[FQ2]: + """ + Decode 128 bytes to a G2 point. + + Parameters + ---------- + data : + The bytes data to decode. + + Returns + ------- + point : Point2D + A point on the curve. + + Raises + ------ + InvalidParameter + Either a field element is invalid or the point is not on the curve. + + """ + if len(data) != 128: + raise InvalidParameter("G2 should be 128 bytes long") + + x0_bytes = buffer_read(data, U256(0), U256(32)) + x0 = int(U256.from_be_bytes(x0_bytes)) + x1_bytes = buffer_read(data, U256(32), U256(32)) + x1 = int(U256.from_be_bytes(x1_bytes)) + + y0_bytes = buffer_read(data, U256(64), U256(32)) + y0 = int(U256.from_be_bytes(y0_bytes)) + y1_bytes = buffer_read(data, U256(96), U256(32)) + y1 = int(U256.from_be_bytes(y1_bytes)) + + if x0 >= field_modulus or x1 >= field_modulus: + raise InvalidParameter("Invalid field element") + if y0 >= field_modulus or y1 >= field_modulus: + raise InvalidParameter("Invalid field element") + + x = FQ2((x1, x0)) + y = FQ2((y1, y0)) + + z = (1, 0) + if x == FQ2((0, 0)) and y == FQ2((0, 0)): + z = (0, 0) + + point = (x, y, FQ2(z)) + + # Check if the point is on the curve + if not is_on_curve(point, b2): + raise InvalidParameter("Point is not on curve") + + return point + + +def alt_bn128_add(evm: Evm) -> None: + """ + The ALT_BN128 addition precompiled contract. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + + # GAS + charge_gas(evm, Uint(150)) + + # OPERATION + try: + p0 = bytes_to_g1(buffer_read(data, U256(0), U256(64))) + p1 = bytes_to_g1(buffer_read(data, U256(64), U256(64))) + except InvalidParameter as e: + raise OutOfGasError from e + + p = add(p0, p1) + x, y = normalize(p) + + evm.output = Uint(x).to_be_bytes32() + Uint(y).to_be_bytes32() + + +def alt_bn128_mul(evm: Evm) -> None: + """ + The ALT_BN128 multiplication precompiled contract. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + + # GAS + charge_gas(evm, Uint(6000)) + + # OPERATION + try: + p0 = bytes_to_g1(buffer_read(data, U256(0), U256(64))) + except InvalidParameter as e: + raise OutOfGasError from e + n = int(U256.from_be_bytes(buffer_read(data, U256(64), U256(32)))) + + p = multiply(p0, n) + x, y = normalize(p) + + evm.output = Uint(x).to_be_bytes32() + Uint(y).to_be_bytes32() + + +def alt_bn128_pairing_check(evm: Evm) -> None: + """ + The ALT_BN128 pairing check precompiled contract. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + + # GAS + charge_gas(evm, Uint(34000 * (len(data) // 192) + 45000)) + + # OPERATION + if len(data) % 192 != 0: + raise OutOfGasError + result = FQ12.one() + for i in range(len(data) // 192): + try: + p = bytes_to_g1(buffer_read(data, U256(192 * i), U256(64))) + q = bytes_to_g2(buffer_read(data, U256(192 * i + 64), U256(128))) + except InvalidParameter as e: + raise OutOfGasError from e + if not is_inf(multiply(p, curve_order)): + raise OutOfGasError + if not is_inf(multiply(q, curve_order)): + raise OutOfGasError + + result *= pairing(q, p) + + if result == FQ12.one(): + evm.output = U256(1).to_be_bytes32() + else: + evm.output = U256(0).to_be_bytes32() diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/blake2f.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/blake2f.py new file mode 100644 index 0000000000..204fbcea28 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/blake2f.py @@ -0,0 +1,42 @@ +""" +Ethereum Virtual Machine (EVM) Blake2 PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the `Blake2` precompiled contract. +""" + +from ethereum.crypto.blake2 import Blake2b + +from ...vm import Evm +from ...vm.gas import GAS_BLAKE2_PER_ROUND, charge_gas +from ..exceptions import InvalidParameter + + +def blake2f(evm: Evm) -> None: + """ + Writes the Blake2 hash to output. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + if len(data) != 213: + raise InvalidParameter + + blake2b = Blake2b() + rounds, h, m, t_0, t_1, f = blake2b.get_blake2_parameters(data) + + charge_gas(evm, GAS_BLAKE2_PER_ROUND * rounds) + if f not in [0, 1]: + raise InvalidParameter + + evm.output = blake2b.compress(rounds, h, m, t_0, t_1, f) diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/__init__.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/__init__.py new file mode 100644 index 0000000000..c99e7573f4 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/__init__.py @@ -0,0 +1,622 @@ +""" +BLS12 381 Precompile. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Precompile for BLS12-381 curve operations. +""" + +from functools import lru_cache +from typing import Tuple + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U256, Uint +from py_ecc.optimized_bls12_381.optimized_curve import ( + FQ, + FQ2, + b, + b2, + curve_order, + is_inf, + is_on_curve, + normalize, +) +from py_ecc.optimized_bls12_381.optimized_curve import ( + multiply as bls12_multiply, +) +from py_ecc.typing import Optimized_Point3D as Point3D + +from ....vm.memory import buffer_read +from ...exceptions import InvalidParameter + +G1_K_DISCOUNT = [ + 1000, + 949, + 848, + 797, + 764, + 750, + 738, + 728, + 719, + 712, + 705, + 698, + 692, + 687, + 682, + 677, + 673, + 669, + 665, + 661, + 658, + 654, + 651, + 648, + 645, + 642, + 640, + 637, + 635, + 632, + 630, + 627, + 625, + 623, + 621, + 619, + 617, + 615, + 613, + 611, + 609, + 608, + 606, + 604, + 603, + 601, + 599, + 598, + 596, + 595, + 593, + 592, + 591, + 589, + 588, + 586, + 585, + 584, + 582, + 581, + 580, + 579, + 577, + 576, + 575, + 574, + 573, + 572, + 570, + 569, + 568, + 567, + 566, + 565, + 564, + 563, + 562, + 561, + 560, + 559, + 558, + 557, + 556, + 555, + 554, + 553, + 552, + 551, + 550, + 549, + 548, + 547, + 547, + 546, + 545, + 544, + 543, + 542, + 541, + 540, + 540, + 539, + 538, + 537, + 536, + 536, + 535, + 534, + 533, + 532, + 532, + 531, + 530, + 529, + 528, + 528, + 527, + 526, + 525, + 525, + 524, + 523, + 522, + 522, + 521, + 520, + 520, + 519, +] + +G2_K_DISCOUNT = [ + 1000, + 1000, + 923, + 884, + 855, + 832, + 812, + 796, + 782, + 770, + 759, + 749, + 740, + 732, + 724, + 717, + 711, + 704, + 699, + 693, + 688, + 683, + 679, + 674, + 670, + 666, + 663, + 659, + 655, + 652, + 649, + 646, + 643, + 640, + 637, + 634, + 632, + 629, + 627, + 624, + 622, + 620, + 618, + 615, + 613, + 611, + 609, + 607, + 606, + 604, + 602, + 600, + 598, + 597, + 595, + 593, + 592, + 590, + 589, + 587, + 586, + 584, + 583, + 582, + 580, + 579, + 578, + 576, + 575, + 574, + 573, + 571, + 570, + 569, + 568, + 567, + 566, + 565, + 563, + 562, + 561, + 560, + 559, + 558, + 557, + 556, + 555, + 554, + 553, + 552, + 552, + 551, + 550, + 549, + 548, + 547, + 546, + 545, + 545, + 544, + 543, + 542, + 541, + 541, + 540, + 539, + 538, + 537, + 537, + 536, + 535, + 535, + 534, + 533, + 532, + 532, + 531, + 530, + 530, + 529, + 528, + 528, + 527, + 526, + 526, + 525, + 524, + 524, +] + +G1_MAX_DISCOUNT = 519 +G2_MAX_DISCOUNT = 524 +MULTIPLIER = Uint(1000) + + +# Note: Caching as a way to optimize client performance can create a DoS +# attack vector for worst-case inputs that trigger only cache misses. This +# should not be relied upon for client performance optimization in +# production systems. +@lru_cache(maxsize=128) +def _bytes_to_g1_cached( + data: bytes, + subgroup_check: bool = False, +) -> Point3D[FQ]: + """ + Internal cached version of `bytes_to_g1` that works with hashable `bytes`. + """ + if len(data) != 128: + raise InvalidParameter("Input should be 128 bytes long") + + x = bytes_to_fq(data[:64]) + y = bytes_to_fq(data[64:]) + + if x >= FQ.field_modulus: + raise InvalidParameter("x >= field modulus") + if y >= FQ.field_modulus: + raise InvalidParameter("y >= field modulus") + + z = 1 + if x == 0 and y == 0: + z = 0 + point = FQ(x), FQ(y), FQ(z) + + if not is_on_curve(point, b): + raise InvalidParameter("G1 point is not on curve") + + if subgroup_check and not is_inf(bls12_multiply(point, curve_order)): + raise InvalidParameter("Subgroup check failed for G1 point.") + + return point + + +def bytes_to_g1( + data: Bytes, + subgroup_check: bool = False, +) -> Point3D[FQ]: + """ + Decode 128 bytes to a G1 point with or without subgroup check. + + Parameters + ---------- + data : + The bytes data to decode. + subgroup_check : bool + Whether to perform a subgroup check on the G1 point. + + Returns + ------- + point : Point3D[FQ] + The G1 point. + + Raises + ------ + InvalidParameter + If a field element is invalid, the point is not on the curve, or the + subgroup check fails. + + """ + # This is needed bc when we slice `Bytes` we get a `bytearray`, + # which is not hashable + return _bytes_to_g1_cached(bytes(data), subgroup_check) + + +def g1_to_bytes( + g1_point: Point3D[FQ], +) -> Bytes: + """ + Encode a G1 point to 128 bytes. + + Parameters + ---------- + g1_point : + The G1 point to encode. + + Returns + ------- + data : Bytes + The encoded data. + + """ + g1_normalized = normalize(g1_point) + x, y = g1_normalized + return int(x).to_bytes(64, "big") + int(y).to_bytes(64, "big") + + +def decode_g1_scalar_pair( + data: Bytes, +) -> Tuple[Point3D[FQ], int]: + """ + Decode 160 bytes to a G1 point and a scalar. + + Parameters + ---------- + data : + The bytes data to decode. + + Returns + ------- + point : Tuple[Point3D[FQ], int] + The G1 point and the scalar. + + Raises + ------ + InvalidParameter + If the subgroup check failed. + + """ + if len(data) != 160: + InvalidParameter("Input should be 160 bytes long") + + point = bytes_to_g1(data[:128], subgroup_check=True) + + m = int.from_bytes(buffer_read(data, U256(128), U256(32)), "big") + + return point, m + + +def bytes_to_fq(data: Bytes) -> FQ: + """ + Decode 64 bytes to a FQ element. + + Parameters + ---------- + data : + The bytes data to decode. + + Returns + ------- + fq : FQ + The FQ element. + + Raises + ------ + InvalidParameter + If the field element is invalid. + + """ + if len(data) != 64: + raise InvalidParameter("FQ should be 64 bytes long") + + c = int.from_bytes(data[:64], "big") + + if c >= FQ.field_modulus: + raise InvalidParameter("Invalid field element") + + return FQ(c) + + +def bytes_to_fq2(data: Bytes) -> FQ2: + """ + Decode 128 bytes to an FQ2 element. + + Parameters + ---------- + data : + The bytes data to decode. + + Returns + ------- + fq2 : FQ2 + The FQ2 element. + + Raises + ------ + InvalidParameter + If the field element is invalid. + + """ + if len(data) != 128: + raise InvalidParameter("FQ2 input should be 128 bytes long") + c_0 = int.from_bytes(data[:64], "big") + c_1 = int.from_bytes(data[64:], "big") + + if c_0 >= FQ.field_modulus: + raise InvalidParameter("Invalid field element") + if c_1 >= FQ.field_modulus: + raise InvalidParameter("Invalid field element") + + return FQ2((c_0, c_1)) + + +# Note: Caching as a way to optimize client performance can create a DoS +# attack vector for worst-case inputs that trigger only cache misses. This +# should not be relied upon for client performance optimization in +# production systems. +@lru_cache(maxsize=128) +def _bytes_to_g2_cached( + data: bytes, + subgroup_check: bool = False, +) -> Point3D[FQ2]: + """ + Internal cached version of `bytes_to_g2` that works with hashable `bytes`. + """ + if len(data) != 256: + raise InvalidParameter("G2 should be 256 bytes long") + + x = bytes_to_fq2(data[:128]) + y = bytes_to_fq2(data[128:]) + + z = (1, 0) + if x == FQ2((0, 0)) and y == FQ2((0, 0)): + z = (0, 0) + + point = x, y, FQ2(z) + + if not is_on_curve(point, b2): + raise InvalidParameter("Point is not on curve") + + if subgroup_check and not is_inf(bls12_multiply(point, curve_order)): + raise InvalidParameter("Subgroup check failed for G2 point.") + + return point + + +def bytes_to_g2( + data: Bytes, + subgroup_check: bool = False, +) -> Point3D[FQ2]: + """ + Decode 256 bytes to a G2 point with or without subgroup check. + + Parameters + ---------- + data : + The bytes data to decode. + subgroup_check : bool + Whether to perform a subgroup check on the G2 point. + + Returns + ------- + point : Point3D[FQ2] + The G2 point. + + Raises + ------ + InvalidParameter + If a field element is invalid, the point is not on the curve, or the + subgroup check fails. + + """ + # This is needed bc when we slice `Bytes` we get a `bytearray`, + # which is not hashable + return _bytes_to_g2_cached(data, subgroup_check) + + +def fq2_to_bytes(fq2: FQ2) -> Bytes: + """ + Encode a FQ2 point to 128 bytes. + + Parameters + ---------- + fq2 : + The FQ2 point to encode. + + Returns + ------- + data : Bytes + The encoded data. + + """ + coord0, coord1 = fq2.coeffs + return int(coord0).to_bytes(64, "big") + int(coord1).to_bytes(64, "big") + + +def g2_to_bytes( + g2_point: Point3D[FQ2], +) -> Bytes: + """ + Encode a G2 point to 256 bytes. + + Parameters + ---------- + g2_point : + The G2 point to encode. + + Returns + ------- + data : Bytes + The encoded data. + + """ + x_coords, y_coords = normalize(g2_point) + return fq2_to_bytes(x_coords) + fq2_to_bytes(y_coords) + + +def decode_g2_scalar_pair( + data: Bytes, +) -> Tuple[Point3D[FQ2], int]: + """ + Decode 288 bytes to a G2 point and a scalar. + + Parameters + ---------- + data : + The bytes data to decode. + + Returns + ------- + point : Tuple[Point3D[FQ2], int] + The G2 point and the scalar. + + Raises + ------ + InvalidParameter + If the subgroup check failed. + + """ + if len(data) != 288: + InvalidParameter("Input should be 288 bytes long") + + point = bytes_to_g2(data[:256], subgroup_check=True) + n = int.from_bytes(data[256 : 256 + 32], "big") + + return point, n diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_g1.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_g1.py new file mode 100644 index 0000000000..ae8eeba0ce --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_g1.py @@ -0,0 +1,151 @@ +""" +Ethereum Virtual Machine (EVM) BLS12 381 CONTRACTS. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of pre-compiles in G1 (curve over base prime field). +""" + +from ethereum_types.numeric import U256, Uint +from py_ecc.bls.hash_to_curve import clear_cofactor_G1, map_to_curve_G1 +from py_ecc.optimized_bls12_381.optimized_curve import FQ +from py_ecc.optimized_bls12_381.optimized_curve import add as bls12_add +from py_ecc.optimized_bls12_381.optimized_curve import ( + multiply as bls12_multiply, +) + +from ....vm import Evm +from ....vm.gas import ( + GAS_BLS_G1_ADD, + GAS_BLS_G1_MAP, + GAS_BLS_G1_MUL, + charge_gas, +) +from ....vm.memory import buffer_read +from ...exceptions import InvalidParameter +from . import ( + G1_K_DISCOUNT, + G1_MAX_DISCOUNT, + MULTIPLIER, + bytes_to_g1, + decode_g1_scalar_pair, + g1_to_bytes, +) + +LENGTH_PER_PAIR = 160 + + +def bls12_g1_add(evm: Evm) -> None: + """ + The bls12_381 G1 point addition precompile. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + InvalidParameter + If the input length is invalid. + + """ + data = evm.message.data + if len(data) != 256: + raise InvalidParameter("Invalid Input Length") + + # GAS + charge_gas(evm, Uint(GAS_BLS_G1_ADD)) + + # OPERATION + p1 = bytes_to_g1(buffer_read(data, U256(0), U256(128))) + p2 = bytes_to_g1(buffer_read(data, U256(128), U256(128))) + + result = bls12_add(p1, p2) + + evm.output = g1_to_bytes(result) + + +def bls12_g1_msm(evm: Evm) -> None: + """ + The bls12_381 G1 multi-scalar multiplication precompile. + Note: This uses the naive approach to multi-scalar multiplication + which is not suitably optimized for production clients. Clients are + required to implement a more efficient algorithm such as the Pippenger + algorithm. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + InvalidParameter + If the input length is invalid. + + """ + data = evm.message.data + if len(data) == 0 or len(data) % LENGTH_PER_PAIR != 0: + raise InvalidParameter("Invalid Input Length") + + # GAS + k = len(data) // LENGTH_PER_PAIR + if k <= 128: + discount = Uint(G1_K_DISCOUNT[k - 1]) + else: + discount = Uint(G1_MAX_DISCOUNT) + + gas_cost = Uint(k) * GAS_BLS_G1_MUL * discount // MULTIPLIER + charge_gas(evm, gas_cost) + + # OPERATION + for i in range(k): + start_index = i * LENGTH_PER_PAIR + end_index = start_index + LENGTH_PER_PAIR + + p, m = decode_g1_scalar_pair(data[start_index:end_index]) + product = bls12_multiply(p, m) + + if i == 0: + result = product + else: + result = bls12_add(result, product) + + evm.output = g1_to_bytes(result) + + +def bls12_map_fp_to_g1(evm: Evm) -> None: + """ + Precompile to map field element to G1. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + InvalidParameter + If the input length is invalid. + + """ + data = evm.message.data + if len(data) != 64: + raise InvalidParameter("Invalid Input Length") + + # GAS + charge_gas(evm, Uint(GAS_BLS_G1_MAP)) + + # OPERATION + fp = int.from_bytes(data, "big") + if fp >= FQ.field_modulus: + raise InvalidParameter("coordinate >= field modulus") + + g1_optimized_3d = clear_cofactor_G1(map_to_curve_G1(FQ(fp))) + evm.output = g1_to_bytes(g1_optimized_3d) diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_g2.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_g2.py new file mode 100644 index 0000000000..473739b78c --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_g2.py @@ -0,0 +1,153 @@ +""" +Ethereum Virtual Machine (EVM) BLS12 381 G2 CONTRACTS. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of pre-compiles in G2 (curve over base prime field). +""" + +from ethereum_types.numeric import U256, Uint +from py_ecc.bls.hash_to_curve import clear_cofactor_G2, map_to_curve_G2 +from py_ecc.optimized_bls12_381.optimized_curve import FQ2 +from py_ecc.optimized_bls12_381.optimized_curve import add as bls12_add +from py_ecc.optimized_bls12_381.optimized_curve import ( + multiply as bls12_multiply, +) + +from ....vm import Evm +from ....vm.gas import ( + GAS_BLS_G2_ADD, + GAS_BLS_G2_MAP, + GAS_BLS_G2_MUL, + charge_gas, +) +from ....vm.memory import buffer_read +from ...exceptions import InvalidParameter +from . import ( + G2_K_DISCOUNT, + G2_MAX_DISCOUNT, + MULTIPLIER, + bytes_to_fq2, + bytes_to_g2, + decode_g2_scalar_pair, + g2_to_bytes, +) + +LENGTH_PER_PAIR = 288 + + +def bls12_g2_add(evm: Evm) -> None: + """ + The bls12_381 G2 point addition precompile. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + InvalidParameter + If the input length is invalid. + + """ + data = evm.message.data + if len(data) != 512: + raise InvalidParameter("Invalid Input Length") + + # GAS + charge_gas(evm, Uint(GAS_BLS_G2_ADD)) + + # OPERATION + p1 = bytes_to_g2(buffer_read(data, U256(0), U256(256))) + p2 = bytes_to_g2(buffer_read(data, U256(256), U256(256))) + + result = bls12_add(p1, p2) + + evm.output = g2_to_bytes(result) + + +def bls12_g2_msm(evm: Evm) -> None: + """ + The bls12_381 G2 multi-scalar multiplication precompile. + Note: This uses the naive approach to multi-scalar multiplication + which is not suitably optimized for production clients. Clients are + required to implement a more efficient algorithm such as the Pippenger + algorithm. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + InvalidParameter + If the input length is invalid. + + """ + data = evm.message.data + if len(data) == 0 or len(data) % LENGTH_PER_PAIR != 0: + raise InvalidParameter("Invalid Input Length") + + # GAS + k = len(data) // LENGTH_PER_PAIR + if k <= 128: + discount = Uint(G2_K_DISCOUNT[k - 1]) + else: + discount = Uint(G2_MAX_DISCOUNT) + + gas_cost = Uint(k) * GAS_BLS_G2_MUL * discount // MULTIPLIER + charge_gas(evm, gas_cost) + + # OPERATION + for i in range(k): + start_index = i * LENGTH_PER_PAIR + end_index = start_index + LENGTH_PER_PAIR + + p, m = decode_g2_scalar_pair(data[start_index:end_index]) + product = bls12_multiply(p, m) + + if i == 0: + result = product + else: + result = bls12_add(result, product) + + evm.output = g2_to_bytes(result) + + +def bls12_map_fp2_to_g2(evm: Evm) -> None: + """ + Precompile to map field element to G2. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + InvalidParameter + If the input length is invalid. + + """ + data = evm.message.data + if len(data) != 128: + raise InvalidParameter("Invalid Input Length") + + # GAS + charge_gas(evm, Uint(GAS_BLS_G2_MAP)) + + # OPERATION + field_element = bytes_to_fq2(data) + assert isinstance(field_element, FQ2) + + fp2 = bytes_to_fq2(data) + g2_3d = clear_cofactor_G2(map_to_curve_G2(fp2)) + + evm.output = g2_to_bytes(g2_3d) diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_pairing.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_pairing.py new file mode 100644 index 0000000000..6cb29a32fd --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/bls12_381/bls12_381_pairing.py @@ -0,0 +1,69 @@ +""" +Ethereum Virtual Machine (EVM) BLS12 381 PAIRING PRE-COMPILE. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the BLS12 381 pairing pre-compile. +""" + +from ethereum_types.numeric import Uint +from py_ecc.optimized_bls12_381 import FQ12, curve_order, is_inf, pairing +from py_ecc.optimized_bls12_381 import multiply as bls12_multiply + +from ....vm import Evm +from ....vm.gas import charge_gas +from ...exceptions import InvalidParameter +from . import bytes_to_g1, bytes_to_g2 + + +def bls12_pairing(evm: Evm) -> None: + """ + The bls12_381 pairing precompile. + + Parameters + ---------- + evm : + The current EVM frame. + + Raises + ------ + InvalidParameter + If the input length is invalid or if sub-group check fails. + + """ + data = evm.message.data + if len(data) == 0 or len(data) % 384 != 0: + raise InvalidParameter("Invalid Input Length") + + # GAS + k = len(data) // 384 + gas_cost = Uint(32600 * k + 37700) + charge_gas(evm, gas_cost) + + # OPERATION + result = FQ12.one() + for i in range(k): + g1_start = Uint(384 * i) + g2_start = Uint(384 * i + 128) + + g1_slice = data[g1_start : g1_start + Uint(128)] + g1_point = bytes_to_g1(bytes(g1_slice)) + if not is_inf(bls12_multiply(g1_point, curve_order)): + raise InvalidParameter("Sub-group check failed for G1 point.") + + g2_slice = data[g2_start : g2_start + Uint(256)] + g2_point = bytes_to_g2(bytes(g2_slice)) + if not is_inf(bls12_multiply(g2_point, curve_order)): + raise InvalidParameter("Sub-group check failed for G2 point.") + + result *= pairing(g2_point, g1_point) + + if result == FQ12.one(): + evm.output = b"\x00" * 31 + b"\x01" + else: + evm.output = b"\x00" * 32 diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/ecrecover.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/ecrecover.py new file mode 100644 index 0000000000..d2eeaf75df --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/ecrecover.py @@ -0,0 +1,64 @@ +""" +Ethereum Virtual Machine (EVM) ECRECOVER PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the ECRECOVER precompiled contract. +""" + +from ethereum_types.numeric import U256 + +from ethereum.crypto.elliptic_curve import SECP256K1N, secp256k1_recover +from ethereum.crypto.hash import Hash32, keccak256 +from ethereum.exceptions import InvalidSignatureError +from ethereum.utils.byte import left_pad_zero_bytes + +from ...vm import Evm +from ...vm.gas import GAS_ECRECOVER, charge_gas +from ...vm.memory import buffer_read + + +def ecrecover(evm: Evm) -> None: + """ + Decrypts the address using elliptic curve DSA recovery mechanism and writes + the address to output. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + + # GAS + charge_gas(evm, GAS_ECRECOVER) + + # OPERATION + message_hash_bytes = buffer_read(data, U256(0), U256(32)) + message_hash = Hash32(message_hash_bytes) + v = U256.from_be_bytes(buffer_read(data, U256(32), U256(32))) + r = U256.from_be_bytes(buffer_read(data, U256(64), U256(32))) + s = U256.from_be_bytes(buffer_read(data, U256(96), U256(32))) + + if v != U256(27) and v != U256(28): + return + if U256(0) >= r or r >= SECP256K1N: + return + if U256(0) >= s or s >= SECP256K1N: + return + + try: + public_key = secp256k1_recover(r, s, v - U256(27), message_hash) + except InvalidSignatureError: + # unable to extract public key + return + + address = keccak256(public_key)[12:32] + padded_address = left_pad_zero_bytes(address, 32) + evm.output = padded_address diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/identity.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/identity.py new file mode 100644 index 0000000000..49a79a4c4a --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/identity.py @@ -0,0 +1,39 @@ +""" +Ethereum Virtual Machine (EVM) IDENTITY PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the `IDENTITY` precompiled contract. +""" + +from ethereum_types.numeric import Uint + +from ethereum.utils.numeric import ceil32 + +from ...vm import Evm +from ...vm.gas import GAS_IDENTITY, GAS_IDENTITY_WORD, charge_gas + + +def identity(evm: Evm) -> None: + """ + Writes the message data to output. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + + # GAS + word_count = ceil32(Uint(len(data))) // Uint(32) + charge_gas(evm, GAS_IDENTITY + GAS_IDENTITY_WORD * word_count) + + # OPERATION + evm.output = data diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/mapping.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/mapping.py new file mode 100644 index 0000000000..a80e2b0235 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/mapping.py @@ -0,0 +1,77 @@ +""" +Precompiled Contract Addresses. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Mapping of precompiled contracts their implementations. +""" + +from typing import Callable, Dict + +from ...fork_types import Address +from . import ( + ALT_BN128_ADD_ADDRESS, + ALT_BN128_MUL_ADDRESS, + ALT_BN128_PAIRING_CHECK_ADDRESS, + BLAKE2F_ADDRESS, + BLS12_G1_ADD_ADDRESS, + BLS12_G1_MSM_ADDRESS, + BLS12_G2_ADD_ADDRESS, + BLS12_G2_MSM_ADDRESS, + BLS12_MAP_FP2_TO_G2_ADDRESS, + BLS12_MAP_FP_TO_G1_ADDRESS, + BLS12_PAIRING_ADDRESS, + ECRECOVER_ADDRESS, + IDENTITY_ADDRESS, + MODEXP_ADDRESS, + P256VERIFY_ADDRESS, + POINT_EVALUATION_ADDRESS, + RIPEMD160_ADDRESS, + SHA256_ADDRESS, +) +from .alt_bn128 import alt_bn128_add, alt_bn128_mul, alt_bn128_pairing_check +from .blake2f import blake2f +from .bls12_381.bls12_381_g1 import ( + bls12_g1_add, + bls12_g1_msm, + bls12_map_fp_to_g1, +) +from .bls12_381.bls12_381_g2 import ( + bls12_g2_add, + bls12_g2_msm, + bls12_map_fp2_to_g2, +) +from .bls12_381.bls12_381_pairing import bls12_pairing +from .ecrecover import ecrecover +from .identity import identity +from .modexp import modexp +from .p256verify import p256verify +from .point_evaluation import point_evaluation +from .ripemd160 import ripemd160 +from .sha256 import sha256 + +PRE_COMPILED_CONTRACTS: Dict[Address, Callable] = { + ECRECOVER_ADDRESS: ecrecover, + SHA256_ADDRESS: sha256, + RIPEMD160_ADDRESS: ripemd160, + IDENTITY_ADDRESS: identity, + MODEXP_ADDRESS: modexp, + ALT_BN128_ADD_ADDRESS: alt_bn128_add, + ALT_BN128_MUL_ADDRESS: alt_bn128_mul, + ALT_BN128_PAIRING_CHECK_ADDRESS: alt_bn128_pairing_check, + BLAKE2F_ADDRESS: blake2f, + POINT_EVALUATION_ADDRESS: point_evaluation, + BLS12_G1_ADD_ADDRESS: bls12_g1_add, + BLS12_G1_MSM_ADDRESS: bls12_g1_msm, + BLS12_G2_ADD_ADDRESS: bls12_g2_add, + BLS12_G2_MSM_ADDRESS: bls12_g2_msm, + BLS12_PAIRING_ADDRESS: bls12_pairing, + BLS12_MAP_FP_TO_G1_ADDRESS: bls12_map_fp_to_g1, + BLS12_MAP_FP2_TO_G2_ADDRESS: bls12_map_fp2_to_g2, + P256VERIFY_ADDRESS: p256verify, +} diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/modexp.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/modexp.py new file mode 100644 index 0000000000..5e7e895b91 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/modexp.py @@ -0,0 +1,175 @@ +""" +Ethereum Virtual Machine (EVM) MODEXP PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the `MODEXP` precompiled contract. +""" + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import U256, Uint + +from ...vm import Evm +from ...vm.exceptions import ExceptionalHalt +from ...vm.gas import charge_gas +from ..memory import buffer_read + + +def modexp(evm: Evm) -> None: + """ + Calculates `(base**exp) % modulus` for arbitrary sized `base`, `exp` and. + `modulus`. The return value is the same length as the modulus. + """ + data = evm.message.data + + # GAS + base_length = U256.from_be_bytes(buffer_read(data, U256(0), U256(32))) + if base_length > U256(1024): + raise ExceptionalHalt("Mod-exp base length is too large") + + exp_length = U256.from_be_bytes(buffer_read(data, U256(32), U256(32))) + if exp_length > U256(1024): + raise ExceptionalHalt("Mod-exp exponent length is too large") + + modulus_length = U256.from_be_bytes(buffer_read(data, U256(64), U256(32))) + if modulus_length > U256(1024): + raise ExceptionalHalt("Mod-exp modulus length is too large") + + exp_start = U256(96) + base_length + + exp_head = U256.from_be_bytes( + buffer_read(data, exp_start, min(U256(32), exp_length)) + ) + + charge_gas( + evm, + gas_cost(base_length, modulus_length, exp_length, exp_head), + ) + + # OPERATION + if base_length == 0 and modulus_length == 0: + evm.output = Bytes() + return + + base = Uint.from_be_bytes(buffer_read(data, U256(96), base_length)) + exp = Uint.from_be_bytes(buffer_read(data, exp_start, exp_length)) + + modulus_start = exp_start + exp_length + modulus = Uint.from_be_bytes( + buffer_read(data, modulus_start, modulus_length) + ) + + if modulus == 0: + evm.output = Bytes(b"\x00") * modulus_length + else: + evm.output = pow(base, exp, modulus).to_bytes( + Uint(modulus_length), "big" + ) + + +def complexity(base_length: U256, modulus_length: U256) -> Uint: + """ + Estimate the complexity of performing a modular exponentiation. + + Parameters + ---------- + base_length : + Length of the array representing the base integer. + + modulus_length : + Length of the array representing the modulus integer. + + Returns + ------- + complexity : `Uint` + Complexity of performing the operation. + + """ + max_length = max(Uint(base_length), Uint(modulus_length)) + words = (max_length + Uint(7)) // Uint(8) + complexity = Uint(16) + if max_length > Uint(32): + complexity = Uint(2) * words ** Uint(2) + return complexity + + +def iterations(exponent_length: U256, exponent_head: U256) -> Uint: + """ + Calculate the number of iterations required to perform a modular + exponentiation. + + Parameters + ---------- + exponent_length : + Length of the array representing the exponent integer. + + exponent_head : + First 32 bytes of the exponent (with leading zero padding if it is + shorter than 32 bytes), as a U256. + + Returns + ------- + iterations : `Uint` + Number of iterations. + + """ + if exponent_length <= U256(32) and exponent_head == U256(0): + count = Uint(0) + elif exponent_length <= U256(32): + bit_length = exponent_head.bit_length() + + if bit_length > Uint(0): + bit_length -= Uint(1) + + count = bit_length + else: + length_part = Uint(16) * (Uint(exponent_length) - Uint(32)) + bits_part = exponent_head.bit_length() + + if bits_part > Uint(0): + bits_part -= Uint(1) + + count = length_part + bits_part + + return max(count, Uint(1)) + + +def gas_cost( + base_length: U256, + modulus_length: U256, + exponent_length: U256, + exponent_head: U256, +) -> Uint: + """ + Calculate the gas cost of performing a modular exponentiation. + + Parameters + ---------- + base_length : + Length of the array representing the base integer. + + modulus_length : + Length of the array representing the modulus integer. + + exponent_length : + Length of the array representing the exponent integer. + + exponent_head : + First 32 bytes of the exponent (with leading zero padding if it is + shorter than 32 bytes), as a U256. + + Returns + ------- + gas_cost : `Uint` + Gas required for performing the operation. + + """ + multiplication_complexity = complexity(base_length, modulus_length) + iteration_count = iterations(exponent_length, exponent_head) + cost = multiplication_complexity * iteration_count + return max(Uint(500), cost) diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/p256verify.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/p256verify.py new file mode 100644 index 0000000000..6f6e7ff4e9 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/p256verify.py @@ -0,0 +1,89 @@ +""" +Ethereum Virtual Machine (EVM) P256VERIFY PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction. +------------ +Implementation of the P256VERIFY precompiled contract. +""" + +from ethereum_types.numeric import U256 + +from ethereum.crypto.elliptic_curve import ( + SECP256R1N, + SECP256R1P, + is_on_curve_secp256r1, + secp256r1_verify, +) +from ethereum.crypto.hash import Hash32 +from ethereum.exceptions import InvalidSignatureError +from ethereum.utils.byte import left_pad_zero_bytes + +from ...vm import Evm +from ...vm.gas import GAS_P256VERIFY, charge_gas +from ...vm.memory import buffer_read + + +def p256verify(evm: Evm) -> None: + """ + Verifies a P-256 signature. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + + # GAS + charge_gas(evm, GAS_P256VERIFY) + + if len(data) != 160: + return + + # OPERATION + message_hash_bytes = buffer_read(data, U256(0), U256(32)) + message_hash = Hash32(message_hash_bytes) + r = U256.from_be_bytes(buffer_read(data, U256(32), U256(32))) + s = U256.from_be_bytes(buffer_read(data, U256(64), U256(32))) + public_key_x = U256.from_be_bytes( + buffer_read(data, U256(96), U256(32)) + ) # qx + public_key_y = U256.from_be_bytes( + buffer_read(data, U256(128), U256(32)) + ) # qy + + # Signature component bounds: + # Both r and s MUST satisfy 0 < r < n and 0 < s < n + if r <= U256(0) or r >= SECP256R1N: + return + if s <= U256(0) or s >= SECP256R1N: + return + + # Public key bounds: + # Both qx and qy MUST satisfy 0 ≤ qx < p and 0 ≤ qy < p + # U256 is unsigned, so we don't need to check for < 0 + if public_key_x >= SECP256R1P: + return + if public_key_y >= SECP256R1P: + return + + # Point should not be at infinity (represented as (0, 0)) + if public_key_x == U256(0) and public_key_y == U256(0): + return + + # Point validity: The point (qx, qy) MUST satisfy the curve equation + # qy^2 ≡ qx^3 + a*qx + b (mod p) + if not is_on_curve_secp256r1(public_key_x, public_key_y): + return + + try: + secp256r1_verify(r, s, public_key_x, public_key_y, message_hash) + except InvalidSignatureError: + return + + evm.output = left_pad_zero_bytes(b"\x01", 32) diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/point_evaluation.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/point_evaluation.py new file mode 100644 index 0000000000..760af47736 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/point_evaluation.py @@ -0,0 +1,72 @@ +""" +Ethereum Virtual Machine (EVM) POINT EVALUATION PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the POINT EVALUATION precompiled contract. +""" + +from ethereum_types.bytes import Bytes, Bytes32, Bytes48 +from ethereum_types.numeric import U256 + +from ethereum.crypto.kzg import ( + KZGCommitment, + kzg_commitment_to_versioned_hash, + verify_kzg_proof, +) + +from ...vm import Evm +from ...vm.exceptions import KZGProofError +from ...vm.gas import GAS_POINT_EVALUATION, charge_gas + +FIELD_ELEMENTS_PER_BLOB = 4096 +BLS_MODULUS = 52435875175126190479447740508185965837690552500527637822603658699938581184513 # noqa: E501 +VERSIONED_HASH_VERSION_KZG = b"\x01" + + +def point_evaluation(evm: Evm) -> None: + """ + A pre-compile that verifies a KZG proof which claims that a blob + (represented by a commitment) evaluates to a given value at a given point. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + if len(data) != 192: + raise KZGProofError + + versioned_hash = data[:32] + z = Bytes32(data[32:64]) + y = Bytes32(data[64:96]) + commitment = KZGCommitment(data[96:144]) + proof = Bytes48(data[144:192]) + + # GAS + charge_gas(evm, GAS_POINT_EVALUATION) + if kzg_commitment_to_versioned_hash(commitment) != versioned_hash: + raise KZGProofError + + # Verify KZG proof with z and y in big endian format + try: + kzg_proof_verification = verify_kzg_proof(commitment, z, y, proof) + except Exception as e: + raise KZGProofError from e + + if not kzg_proof_verification: + raise KZGProofError + + # Return FIELD_ELEMENTS_PER_BLOB and BLS_MODULUS as padded + # 32 byte big endian values + evm.output = Bytes( + U256(FIELD_ELEMENTS_PER_BLOB).to_be_bytes32() + + U256(BLS_MODULUS).to_be_bytes32() + ) diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/ripemd160.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/ripemd160.py new file mode 100644 index 0000000000..74cefd93af --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/ripemd160.py @@ -0,0 +1,44 @@ +""" +Ethereum Virtual Machine (EVM) RIPEMD160 PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the `RIPEMD160` precompiled contract. +""" + +import hashlib + +from ethereum_types.numeric import Uint + +from ethereum.utils.byte import left_pad_zero_bytes +from ethereum.utils.numeric import ceil32 + +from ...vm import Evm +from ...vm.gas import GAS_RIPEMD160, GAS_RIPEMD160_WORD, charge_gas + + +def ripemd160(evm: Evm) -> None: + """ + Writes the ripemd160 hash to output. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + + # GAS + word_count = ceil32(Uint(len(data))) // Uint(32) + charge_gas(evm, GAS_RIPEMD160 + GAS_RIPEMD160_WORD * word_count) + + # OPERATION + hash_bytes = hashlib.new("ripemd160", data).digest() + padded_hash = left_pad_zero_bytes(hash_bytes, 32) + evm.output = padded_hash diff --git a/src/ethereum/forks/amsterdam/vm/precompiled_contracts/sha256.py b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/sha256.py new file mode 100644 index 0000000000..b0d1517b40 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/precompiled_contracts/sha256.py @@ -0,0 +1,41 @@ +""" +Ethereum Virtual Machine (EVM) SHA256 PRECOMPILED CONTRACT. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the `SHA256` precompiled contract. +""" + +import hashlib + +from ethereum_types.numeric import Uint + +from ethereum.utils.numeric import ceil32 + +from ...vm import Evm +from ...vm.gas import GAS_SHA256, GAS_SHA256_WORD, charge_gas + + +def sha256(evm: Evm) -> None: + """ + Writes the sha256 hash to output. + + Parameters + ---------- + evm : + The current EVM frame. + + """ + data = evm.message.data + + # GAS + word_count = ceil32(Uint(len(data))) // Uint(32) + charge_gas(evm, GAS_SHA256 + GAS_SHA256_WORD * word_count) + + # OPERATION + evm.output = hashlib.sha256(data).digest() diff --git a/src/ethereum/forks/amsterdam/vm/runtime.py b/src/ethereum/forks/amsterdam/vm/runtime.py new file mode 100644 index 0000000000..505b3488de --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/runtime.py @@ -0,0 +1,69 @@ +""" +Ethereum Virtual Machine (EVM) Runtime Operations. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Runtime related operations used while executing EVM code. +""" + +from typing import Set + +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import Uint, ulen + +from .instructions import Ops + + +def get_valid_jump_destinations(code: Bytes) -> Set[Uint]: + """ + Analyze the evm code to obtain the set of valid jump destinations. + + Valid jump destinations are defined as follows: + * The jump destination is less than the length of the code. + * The jump destination should have the `JUMPDEST` opcode (0x5B). + * The jump destination shouldn't be part of the data corresponding to + `PUSH-N` opcodes. + + Note - Jump destinations are 0-indexed. + + Parameters + ---------- + code : + The EVM code which is to be executed. + + Returns + ------- + valid_jump_destinations: `Set[Uint]` + The set of valid jump destinations in the code. + + """ + valid_jump_destinations = set() + pc = Uint(0) + + while pc < ulen(code): + try: + current_opcode = Ops(code[pc]) + except ValueError: + # Skip invalid opcodes, as they don't affect the jumpdest + # analysis. Nevertheless, such invalid opcodes would be caught + # and raised when the interpreter runs. + pc += Uint(1) + continue + + if current_opcode == Ops.JUMPDEST: + valid_jump_destinations.add(pc) + elif Ops.PUSH1.value <= current_opcode.value <= Ops.PUSH32.value: + # If PUSH-N opcodes are encountered, skip the current opcode along + # with the trailing data segment corresponding to the PUSH-N + # opcodes. + push_data_size = current_opcode.value - Ops.PUSH1.value + 1 + pc += Uint(push_data_size) + + pc += Uint(1) + + return valid_jump_destinations diff --git a/src/ethereum/forks/amsterdam/vm/stack.py b/src/ethereum/forks/amsterdam/vm/stack.py new file mode 100644 index 0000000000..a87b0a4707 --- /dev/null +++ b/src/ethereum/forks/amsterdam/vm/stack.py @@ -0,0 +1,58 @@ +""" +Ethereum Virtual Machine (EVM) Stack. + +.. contents:: Table of Contents + :backlinks: none + :local: + +Introduction +------------ + +Implementation of the stack operators for the EVM. +""" + +from typing import List + +from ethereum_types.numeric import U256 + +from .exceptions import StackOverflowError, StackUnderflowError + + +def pop(stack: List[U256]) -> U256: + """ + Pops the top item off of `stack`. + + Parameters + ---------- + stack : + EVM stack. + + Returns + ------- + value : `U256` + The top element on the stack. + + """ + if len(stack) == 0: + raise StackUnderflowError + + return stack.pop() + + +def push(stack: List[U256], value: U256) -> None: + """ + Pushes `value` onto `stack`. + + Parameters + ---------- + stack : + EVM stack. + + value : + Item to be pushed onto `stack`. + + """ + if len(stack) == 1024: + raise StackOverflowError + + return stack.append(value) From 2a2d0d5ed92d53fa64e87c69fd0e6537974d950c Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 8 Dec 2025 18:55:17 -0700 Subject: [PATCH 02/51] fix(spec-tools): Fix lint by adding TODO docstring for Amsterdam init --- src/ethereum/forks/amsterdam/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/__init__.py b/src/ethereum/forks/amsterdam/__init__.py index ecba6c2f8f..de18ac5d18 100644 --- a/src/ethereum/forks/amsterdam/__init__.py +++ b/src/ethereum/forks/amsterdam/__init__.py @@ -1,4 +1,9 @@ +""" +The Amsterdam fork. -from ethereum.fork_criteria import Unscheduled, ForkCriteria +TODO: Update with information for included EIPs as other forks do. +""" + +from ethereum.fork_criteria import ForkCriteria, Unscheduled FORK_CRITERIA: ForkCriteria = Unscheduled(order_index=0) From 3113f47b4fee26a001fb062fe8d67f7d90622772 Mon Sep 17 00:00:00 2001 From: fselmo Date: Tue, 11 Nov 2025 08:18:46 -0700 Subject: [PATCH 03/51] feat(specs): Implement EIP-7928: Block-Level Access Lists fix(tests): Fix Amsterdam filling after rebase fix(specs): Fix issues with new ruff + mypy rules after rebase - bal -> block_access_list; re-add custom rlp encoding for block access list - bytes to uint - move away from method-style - Update EIP-7928 implementation: system contracts at index 0, migrate to RLP - System contracts (parent hash, beacon root) now use block_access_index 0 - Transactions use block_access_index 1 to len(transactions) - Post-execution changes use block_access_index len(transactions) + 1 - Migrated from SSZ to RLP encoding as per updated EIP-7928 spec - Updated all tests to match new API and structure - Replaced tx_index with block_access_index throughout codebase - add system contract logic - add markdown docstrings - update BAL format; address comments - ssz encoding and bal validation - six ssz types - bal tests - balspecs fix: do not track setting empty code to a new account (#19) fix: track implicit SLOAD within SSTORE for OOG cases (#18) refactor: Put back explicit acct tracking outside 7702 delegation path (#17) fix non-tracked 7702 authority for invalid delegations (#16) * fix non-tracked 7702 authority for invalid delegations * fix: lint issues * fix: track delegation target when loaded as call target * fix: track delegation target when loaded as call target from call opcodes * chore: fix issues with documentation generation Fix self-destruct cases with pre-execution balance cache / tracking * fix self-destruct implementation * fix self-destruct tracking balance * fix it in the bal finalization by filtering * add balance reset and fix tests * simplify pre-balance tracking not using snapshots fix duplicated code entries for in transaction self destruct fix self destruct in same transaction bug fix call/delagate call tracking bug fix zero-value transfer tracking (#6) * fix zero-value transfer tracking * fix reverted frame tracking * rename variables * fix missing addresses bug * fix: docs run & move imports to top of file refactor: move rlp_utils to block_access_lists; bal -> block_access_lists Some remaining fixes due to large refactor in `forks/osaka`: - Move BALs from amsterdam -> forks/amsterdam - rename: build -> build_block_access_list - fix docc issues move state change tracker to State correct system contract addresses Fixes to communicate with BALs EEST branch: - fix(bal): Initialize the state tracker before system contract calls - We were missing system contract calls to beacon roots and history contracts. This change initializes the state tracker before system contract calls and passes the tracker to these calls if post-Amsterdam. - fix(docs): Fix issues with toxenvs: lint, doc, json_infra - fix(t8n): Only initialize the bal_change_tracker for amsterdam - feat(fork criteria): Index upcoming forks for better ordering / fix issues - chore(forks): Fix issues from lint after rebase with Osaka latest - fix(setuptools): Update packages to include amsterdam - chore(lint): Fix 'tox -e static' issues - Fix bug in tracker Manually cherry-picked from e72991bf3876563900d5c2bcc2442b0a1eeb439f Author: nerolation - chore(tests): Attempt to resolve issues with CI tests - chore(lint): fix issues from running ``tox -e static`` locally - refactor(bal): Send BAL as a list over t8n tool - fix(amsterdam): Add change tracker to state test in t8n - chore(lint,tests): Fix tests after moving bal from osaka -> amsterdam - chore(forks): Move bals from Osaka to Amsterdam - chore(lint): Fix lint issues - refactor(bal): Send the full bal object and bal_hash over t8n - If we send the full object over JSON, we can model_validate() on ESST. - If we send the hash, once we fill the pydantic model, we can get the rlp and the hash and validate that our objects match while only really validating the parts of the BAL we are interested in for each test. - chore: point to working eest branch - chore(bals): Remove unused SSZ utils.py The SSZ implementation is no longer needed as we are now using RLP - refactor(bals): Clean up BAL module types and imports - Bytes -> Bytes32 type for storage slots - Remove unused imports / fix imports / fix linting - Update function signatures to match tracker - fix(bals-tx-index): Track bal indexes in t8n Keep track of BAL index state in t8n --- .../execution_testing/fixtures/blockchain.py | 16 + .../src/execution_testing/forks/base_fork.py | 19 + .../execution_testing/forks/forks/forks.py | 37 + .../src/execution_testing/specs/blockchain.py | 37 +- pyproject.toml | 20 + .../amsterdam/block_access_lists/__init__.py | 57 ++ .../amsterdam/block_access_lists/builder.py | 435 ++++++++++++ .../amsterdam/block_access_lists/rlp_types.py | 130 ++++ .../amsterdam/block_access_lists/rlp_utils.py | 232 ++++++ .../amsterdam/block_access_lists/tracker.py | 667 ++++++++++++++++++ src/ethereum/forks/amsterdam/blocks.py | 18 + src/ethereum/forks/amsterdam/fork.py | 63 +- src/ethereum/forks/amsterdam/state.py | 84 ++- src/ethereum/forks/amsterdam/vm/__init__.py | 6 + .../forks/amsterdam/vm/eoa_delegation.py | 12 + .../amsterdam/vm/instructions/environment.py | 17 +- .../amsterdam/vm/instructions/storage.py | 36 +- .../forks/amsterdam/vm/instructions/system.py | 37 +- .../forks/amsterdam/vm/interpreter.py | 21 + src/ethereum/forks/osaka/vm/eoa_delegation.py | 1 + src/ethereum/genesis.py | 6 + .../evm_tools/loaders/fork_loader.py | 17 + .../evm_tools/t8n/__init__.py | 43 +- src/ethereum_spec_tools/evm_tools/t8n/env.py | 3 + .../evm_tools/t8n/t8n_types.py | 94 +++ whitelist.txt | 8 +- 26 files changed, 2083 insertions(+), 33 deletions(-) create mode 100644 src/ethereum/forks/amsterdam/block_access_lists/__init__.py create mode 100644 src/ethereum/forks/amsterdam/block_access_lists/builder.py create mode 100644 src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py create mode 100644 src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py create mode 100644 src/ethereum/forks/amsterdam/block_access_lists/tracker.py diff --git a/packages/testing/src/execution_testing/fixtures/blockchain.py b/packages/testing/src/execution_testing/fixtures/blockchain.py index d5476034ab..bb8ecd943a 100644 --- a/packages/testing/src/execution_testing/fixtures/blockchain.py +++ b/packages/testing/src/execution_testing/fixtures/blockchain.py @@ -199,6 +199,9 @@ class FixtureHeader(CamelModel): requests_hash: ( Annotated[Hash, HeaderForkRequirement("requests")] | None ) = Field(None) + block_access_list_hash: ( + Annotated[Hash, HeaderForkRequirement("bal_hash")] | None + ) = Field(None, alias="blockAccessListHash") fork: Fork | None = Field(None, exclude=True) @@ -283,6 +286,11 @@ def genesis(cls, fork: Fork, env: Environment, state_root: Hash) -> Self: "requests_hash": Requests() if fork.header_requests_required(block_number=0, timestamp=0) else None, + "block_access_list_hash": ( + BlockAccessList().rlp_hash + if fork.header_bal_hash_required(block_number=0, timestamp=0) + else None + ), "fork": fork, } return cls(**environment_values, **extras) @@ -408,6 +416,14 @@ def from_fixture_header( "Invalid header for engine_newPayload" ) + if fork.engine_execution_payload_block_access_list( + block_number=header.number, timestamp=header.timestamp + ): + if block_access_list is None: + raise ValueError( + f"`block_access_list` is required in engine `ExecutionPayload` for >={fork}." + ) + execution_payload = FixtureExecutionPayload.from_fixture_header( header=header, transactions=transactions, diff --git a/packages/testing/src/execution_testing/forks/base_fork.py b/packages/testing/src/execution_testing/forks/base_fork.py index 34979e8c49..7d4e13eb92 100644 --- a/packages/testing/src/execution_testing/forks/base_fork.py +++ b/packages/testing/src/execution_testing/forks/base_fork.py @@ -345,6 +345,14 @@ def header_requests_required( """Return true if the header must contain beacon chain requests.""" pass + @classmethod + @abstractmethod + def header_bal_hash_required( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """Return true if the header must contain block access list hash.""" + pass + # Gas related abstract methods @classmethod @@ -710,6 +718,17 @@ def engine_new_payload_target_blobs_per_block( """ pass + @classmethod + @abstractmethod + def engine_execution_payload_block_access_list( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """ + Return `True` if the engine api version requires execution payload to + include a `block_access_list`. + """ + pass + @classmethod @abstractmethod def engine_payload_attribute_target_blobs_per_block( diff --git a/packages/testing/src/execution_testing/forks/forks/forks.py b/packages/testing/src/execution_testing/forks/forks/forks.py index 4a1af669a7..d1b2dcf505 100644 --- a/packages/testing/src/execution_testing/forks/forks/forks.py +++ b/packages/testing/src/execution_testing/forks/forks/forks.py @@ -443,6 +443,14 @@ def header_requests_required( del block_number, timestamp return False + @classmethod + def header_bal_hash_required( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """At genesis, header must not contain block access list hash.""" + del block_number, timestamp + return False + @classmethod def engine_new_payload_version( cls, *, block_number: int = 0, timestamp: int = 0 @@ -483,6 +491,14 @@ def engine_new_payload_requests( del block_number, timestamp return False + @classmethod + def engine_execution_payload_block_access_list( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """At genesis, payloads do not have block access list.""" + del block_number, timestamp + return False + @classmethod def engine_new_payload_target_blobs_per_block( cls, @@ -2462,6 +2478,16 @@ class BPO5(BPO4, bpo_fork=True): class Amsterdam(Osaka): """Amsterdam fork.""" + @classmethod + def header_bal_hash_required( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """ + From Amsterdam, header must contain block access list hash (EIP-7928). + """ + del block_number, timestamp + return True + @classmethod def is_deployed(cls) -> bool: """Return True if this fork is deployed.""" @@ -2475,6 +2501,17 @@ def engine_new_payload_version( del block_number, timestamp return 5 + @classmethod + def engine_execution_payload_block_access_list( + cls, *, block_number: int = 0, timestamp: int = 0 + ) -> bool: + """ + From Amsterdam, engine execution payload includes `block_access_list` + as a parameter. + """ + del block_number, timestamp + return True + class EOFv1(Prague, solc_name="cancun"): """EOF fork.""" diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 1e1d4b63dc..6c1ca500d6 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -687,6 +687,24 @@ def generate_block_data( ) requests_list = block.requests + if self.fork.header_bal_hash_required( + block_number=header.number, timestamp=header.timestamp + ): + assert ( + transition_tool_output.result.block_access_list is not None + ), ( + "Block access list is required for this block but was not provided " + "by the transition tool" + ) + + rlp = transition_tool_output.result.block_access_list.rlp + computed_bal_hash = Hash(rlp.keccak256()) + assert computed_bal_hash == header.block_access_list_hash, ( + "Block access list hash in header does not match the " + f"computed hash from BAL: {header.block_access_list_hash} " + f"!= {computed_bal_hash}" + ) + if block.rlp_modifier is not None: # Modify any parameter specified in the `rlp_modifier` after # transition tool processing. @@ -695,6 +713,23 @@ def generate_block_data( self.fork ) # Deleted during `apply` because `exclude=True` + # Process block access list - apply transformer if present for invalid + # tests + t8n_bal = transition_tool_output.result.block_access_list + bal = t8n_bal + if ( + block.expected_block_access_list is not None + and t8n_bal is not None + ): + block.expected_block_access_list.verify_against(t8n_bal) + + bal = block.expected_block_access_list.modify_if_invalid_test( + t8n_bal + ) + if bal != t8n_bal: + # If the BAL was modified, update the header hash + header.block_access_list_hash = Hash(bal.rlp.keccak256()) + built_block = BuiltBlock( header=header, alloc=transition_tool_output.alloc, @@ -708,7 +743,7 @@ def generate_block_data( expected_exception=block.exception, engine_api_error_code=block.engine_api_error_code, fork=self.fork, - block_access_list=None, + block_access_list=bal, ) try: diff --git a/pyproject.toml b/pyproject.toml index 4bf3928f95..61b5665e9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,13 @@ packages = [ "ethereum.forks.osaka.vm.instructions", "ethereum.forks.osaka.vm.precompiled_contracts", "ethereum.forks.osaka.vm.precompiled_contracts.bls12_381", + "ethereum.forks.amsterdam", + "ethereum.forks.amsterdam.block_access_lists", + "ethereum.forks.amsterdam.utils", + "ethereum.forks.amsterdam.vm", + "ethereum.forks.amsterdam.vm.instructions", + "ethereum.forks.amsterdam.vm.precompiled_contracts", + "ethereum.forks.amsterdam.vm.precompiled_contracts.bls12_381", ] [tool.setuptools.package-data] @@ -372,6 +379,15 @@ ignore = [ "src/ethereum_spec_tools/evm_tools/t8n/evm_trace.py" = [ "N815" # The traces must use camel case in JSON property names ] +"src/ethereum/forks/amsterdam/blocks.py" = [ + "E501" # Line too long - needed for long ref links +] + "src/ethereum/forks/amsterdam/block_access_lists/builder.py" = [ + "E501" # Line too long - needed for long ref links + ] +"src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py" = [ + "E501" # Line too long - needed for long ref links + ] "tests/*" = ["ARG001"] "vulture_whitelist.py" = [ "B018", # Useless expression (intentional for Vulture whitelisting) @@ -379,6 +395,10 @@ ignore = [ "F405", # Undefined names from star imports ] +[tool.ruff.lint.mccabe] +# Set the maximum allowed cyclomatic complexity. C901 default is 10. +max-complexity = 7 + [tool.codespell] builtin = "clear,code,usage" # Built-in dictionaries to use skip = [ # Don't check these files/folders diff --git a/src/ethereum/forks/amsterdam/block_access_lists/__init__.py b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py new file mode 100644 index 0000000000..856ab832bc --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py @@ -0,0 +1,57 @@ +""" +Block Access Lists (EIP-7928) implementation for Ethereum Amsterdam fork. +""" + +from .builder import ( + BlockAccessListBuilder, + add_balance_change, + add_code_change, + add_nonce_change, + add_storage_read, + add_storage_write, + add_touched_account, + build_block_access_list, +) +from .rlp_utils import ( + compute_block_access_list_hash, + rlp_encode_block_access_list, + validate_block_access_list_against_execution, +) +from .tracker import ( + StateChangeTracker, + begin_call_frame, + commit_call_frame, + rollback_call_frame, + set_block_access_index, + track_address_access, + track_balance_change, + track_code_change, + track_nonce_change, + track_storage_read, + track_storage_write, +) + +__all__ = [ + "BlockAccessListBuilder", + "StateChangeTracker", + "add_balance_change", + "add_code_change", + "add_nonce_change", + "add_storage_read", + "add_storage_write", + "add_touched_account", + "begin_call_frame", + "build_block_access_list", + "commit_call_frame", + "compute_block_access_list_hash", + "rollback_call_frame", + "set_block_access_index", + "rlp_encode_block_access_list", + "track_address_access", + "track_balance_change", + "track_code_change", + "track_nonce_change", + "track_storage_read", + "track_storage_write", + "validate_block_access_list_against_execution", +] diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py new file mode 100644 index 0000000000..a9d6ee9930 --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -0,0 +1,435 @@ +""" +Implements the Block Access List builder that tracks all account +and storage accesses during block execution and constructs the final +[`BlockAccessList`]. + +The builder follows a two-phase approach: + +1. **Collection Phase**: During transaction execution, all state accesses are + recorded via the tracking functions. +2. **Build Phase**: After block execution, the accumulated data is sorted + and encoded into the final deterministic format. + +[`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Set + +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U64, U256 + +from ..fork_types import Address +from .rlp_types import ( + AccountChanges, + BalanceChange, + BlockAccessIndex, + BlockAccessList, + CodeChange, + NonceChange, + SlotChanges, + StorageChange, +) + + +@dataclass +class AccountData: + """ + Account data stored in the builder during block execution. + + This dataclass tracks all changes made to a single account throughout + the execution of a block, organized by the type of change and the + transaction index where it occurred. + """ + + storage_changes: Dict[Bytes32, List[StorageChange]] = field( + default_factory=dict + ) + """ + Mapping from storage slot to list of changes made to that slot. + Each change includes the transaction index and new value. + """ + + storage_reads: Set[Bytes32] = field(default_factory=set) + """ + Set of storage slots that were read but not modified. + """ + + balance_changes: List[BalanceChange] = field(default_factory=list) + """ + List of balance changes for this account, ordered by transaction index. + """ + + nonce_changes: List[NonceChange] = field(default_factory=list) + """ + List of nonce changes for this account, ordered by transaction index. + """ + + code_changes: List[CodeChange] = field(default_factory=list) + """ + List of code changes (contract deployments) for this account, + ordered by transaction index. + """ + + +@dataclass +class BlockAccessListBuilder: + """ + Builder for constructing [`BlockAccessList`] efficiently during transaction + execution. + + The builder accumulates all account and storage accesses during block + execution and constructs a deterministic access list. Changes are tracked + by address, field type, and transaction index to enable efficient + reconstruction of state changes. + + [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 + """ + + accounts: Dict[Address, AccountData] = field(default_factory=dict) + """ + Mapping from account address to its tracked changes during block execution. + """ + + +def ensure_account(builder: BlockAccessListBuilder, address: Address) -> None: + """ + Ensure an account exists in the builder's tracking structure. + + Creates an empty [`AccountData`] entry for the given address if it + doesn't already exist. This function is idempotent and safe to call + multiple times for the same address. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address to ensure exists. + + [`AccountData`] : + ref:ethereum.forks.amsterdam.block_access_lists.builder.AccountData + + """ + if address not in builder.accounts: + builder.accounts[address] = AccountData() + + +def add_storage_write( + builder: BlockAccessListBuilder, + address: Address, + slot: Bytes32, + block_access_index: BlockAccessIndex, + new_value: Bytes32, +) -> None: + """ + Add a storage write operation to the block access list. + + Records a storage slot modification for a given address at a specific + transaction index. Multiple writes to the same slot are tracked + separately, maintaining the order and transaction index of each change. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose storage is being modified. + slot : + The storage slot being written to. + block_access_index : + The block access index for this change (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + new_value : + The new value being written to the storage slot. + + """ + ensure_account(builder, address) + + if slot not in builder.accounts[address].storage_changes: + builder.accounts[address].storage_changes[slot] = [] + + change = StorageChange( + block_access_index=block_access_index, new_value=new_value + ) + builder.accounts[address].storage_changes[slot].append(change) + + +def add_storage_read( + builder: BlockAccessListBuilder, address: Address, slot: Bytes32 +) -> None: + """ + Add a storage read operation to the block access list. + + Records that a storage slot was read during execution. Storage slots + that are both read and written will only appear in the storage changes + list, not in the storage reads list, as per [EIP-7928]. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose storage is being read. + slot : + The storage slot being read. + + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + + """ + ensure_account(builder, address) + builder.accounts[address].storage_reads.add(slot) + + +def add_balance_change( + builder: BlockAccessListBuilder, + address: Address, + block_access_index: BlockAccessIndex, + post_balance: U256, +) -> None: + """ + Add a balance change to the block access list. + + Records the post-transaction balance for an account after it has been + modified. This includes changes from transfers, gas fees, block rewards, + and any other balance-affecting operations. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose balance changed. + block_access_index : + The block access index for this change (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + post_balance : + The account balance after the change as U256. + + """ + ensure_account(builder, address) + + # Balance value is already U256 + balance_value = post_balance + + # Check if we already have a balance change for this tx_index and update it + # This ensures we only track the final balance per transaction + existing_changes = builder.accounts[address].balance_changes + for i, existing in enumerate(existing_changes): + if existing.block_access_index == block_access_index: + # Update the existing balance change with the new balance + existing_changes[i] = BalanceChange( + block_access_index=block_access_index, + post_balance=balance_value, + ) + return + + # No existing change for this tx_index, add a new one + change = BalanceChange( + block_access_index=block_access_index, post_balance=balance_value + ) + builder.accounts[address].balance_changes.append(change) + + +def add_nonce_change( + builder: BlockAccessListBuilder, + address: Address, + block_access_index: BlockAccessIndex, + new_nonce: U64, +) -> None: + """ + Add a nonce change to the block access list. + + Records a nonce increment for an account. This occurs when an EOA sends + a transaction or when a contract performs [`CREATE`] or [`CREATE2`] + operations. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address whose nonce changed. + block_access_index : + The block access index for this change (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + new_nonce : + The new nonce value after the change. + + [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 + + """ + ensure_account(builder, address) + + # Check if we already have a nonce change for this tx_index and update it + # This ensures we only track the final nonce per transaction + existing_changes = builder.accounts[address].nonce_changes + for i, existing in enumerate(existing_changes): + if existing.block_access_index == block_access_index: + # Update the existing nonce change with the new nonce + existing_changes[i] = NonceChange( + block_access_index=block_access_index, new_nonce=new_nonce + ) + return + + # No existing change for this tx_index, add a new one + change = NonceChange( + block_access_index=block_access_index, new_nonce=new_nonce + ) + builder.accounts[address].nonce_changes.append(change) + + +def add_code_change( + builder: BlockAccessListBuilder, + address: Address, + block_access_index: BlockAccessIndex, + new_code: Bytes, +) -> None: + """ + Add a code change to the block access list. + + Records contract code deployment or modification. This typically occurs + during contract creation via [`CREATE`], [`CREATE2`], or [`SETCODE`] + operations. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address receiving new code. + block_access_index : + The block access index for this change (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + new_code : + The deployed contract bytecode. + + [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 + + """ + ensure_account(builder, address) + + # Check if we already have a code change for this block_access_index + # This handles the case of in-transaction selfdestructs where code is + # first deployed and then cleared in the same transaction + existing_changes = builder.accounts[address].code_changes + for i, existing in enumerate(existing_changes): + if existing.block_access_index == block_access_index: + # Replace the existing code change with the new one + # For selfdestructs, this ensures we only record the final state (empty code) + existing_changes[i] = CodeChange( + block_access_index=block_access_index, new_code=new_code + ) + return + + # No existing change for this block_access_index, add a new one + change = CodeChange( + block_access_index=block_access_index, new_code=new_code + ) + builder.accounts[address].code_changes.append(change) + + +def add_touched_account( + builder: BlockAccessListBuilder, address: Address +) -> None: + """ + Add an account that was accessed but not modified. + + Records that an account was accessed during execution without any state + changes. This is used for operations like [`EXTCODEHASH`], [`BALANCE`], + [`EXTCODESIZE`], and [`EXTCODECOPY`] that read account data without + modifying it. + + Parameters + ---------- + builder : + The block access list builder instance. + address : + The account address that was accessed. + + [`EXTCODEHASH`] : + ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodehash + [`BALANCE`] : + ref:ethereum.forks.amsterdam.vm.instructions.environment.balance + [`EXTCODESIZE`] : + ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodesize + [`EXTCODECOPY`] : + ref:ethereum.forks.amsterdam.vm.instructions.environment.extcodecopy + + """ + ensure_account(builder, address) + + +def build_block_access_list( + builder: BlockAccessListBuilder, +) -> BlockAccessList: + """ + Build the final [`BlockAccessList`] from accumulated changes. + + Constructs a deterministic block access list by sorting all accumulated + changes. The resulting list is ordered by: + + 1. Account addresses (lexicographically) + 2. Within each account: + - Storage slots (lexicographically) + - Transaction indices (numerically) for each change type + + Parameters + ---------- + builder : + The block access list builder containing all tracked changes. + + Returns + ------- + block_access_list : + The final sorted and encoded block access list. + + [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 + + """ + account_changes_list = [] + + for address, changes in builder.accounts.items(): + storage_changes = [] + for slot, slot_changes in changes.storage_changes.items(): + sorted_changes = tuple( + sorted(slot_changes, key=lambda x: x.block_access_index) + ) + storage_changes.append( + SlotChanges(slot=slot, changes=sorted_changes) + ) + + storage_reads = [] + for slot in changes.storage_reads: + if slot not in changes.storage_changes: + storage_reads.append(slot) + + balance_changes = tuple( + sorted(changes.balance_changes, key=lambda x: x.block_access_index) + ) + nonce_changes = tuple( + sorted(changes.nonce_changes, key=lambda x: x.block_access_index) + ) + code_changes = tuple( + sorted(changes.code_changes, key=lambda x: x.block_access_index) + ) + + storage_changes.sort(key=lambda x: x.slot) + storage_reads.sort() + + account_change = AccountChanges( + address=address, + storage_changes=tuple(storage_changes), + storage_reads=tuple(storage_reads), + balance_changes=balance_changes, + nonce_changes=nonce_changes, + code_changes=code_changes, + ) + + account_changes_list.append(account_change) + + account_changes_list.sort(key=lambda x: x.address) + + return BlockAccessList(account_changes=tuple(account_changes_list)) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py new file mode 100644 index 0000000000..e4d37d6a74 --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py @@ -0,0 +1,130 @@ +""" +Defines the RLP data structures for Block-Level Access Lists +as specified in EIP-7928. These structures enable efficient encoding and +decoding of all accounts and storage locations accessed during block execution. + +The encoding follows the pattern: +address -> field -> block_access_index -> change. +""" + +from dataclasses import dataclass +from typing import Tuple + +from ethereum_types.bytes import Bytes, Bytes20, Bytes32 +from ethereum_types.frozen import slotted_freezable +from ethereum_types.numeric import U64, U256, Uint + +# Type aliases for clarity (matching EIP-7928 specification) +Address = Bytes20 +StorageKey = Bytes32 +StorageValue = Bytes32 +CodeData = Bytes +BlockAccessIndex = Uint # uint16 in the spec, but using Uint for compatibility +Balance = U256 # Post-transaction balance in wei +Nonce = U64 + +# Constants chosen to support a 630m block gas limit +MAX_TXS = 30_000 +# MAX_SLOTS = 300_000 +# MAX_ACCOUNTS = 300_000 +MAX_CODE_SIZE = 24_576 +MAX_CODE_CHANGES = 1 + + +@slotted_freezable +@dataclass +class StorageChange: + """ + Storage change: [block_access_index, new_value]. + RLP encoded as a list. + """ + + block_access_index: BlockAccessIndex + new_value: StorageValue + + +@slotted_freezable +@dataclass +class BalanceChange: + """ + Balance change: [block_access_index, post_balance]. + RLP encoded as a list. + """ + + block_access_index: BlockAccessIndex + post_balance: Balance + + +@slotted_freezable +@dataclass +class NonceChange: + """ + Nonce change: [block_access_index, new_nonce]. + RLP encoded as a list. + """ + + block_access_index: BlockAccessIndex + new_nonce: Nonce + + +@slotted_freezable +@dataclass +class CodeChange: + """ + Code change: [block_access_index, new_code]. + RLP encoded as a list. + """ + + block_access_index: BlockAccessIndex + new_code: CodeData + + +@slotted_freezable +@dataclass +class SlotChanges: + """ + All changes to a single storage slot: [slot, [changes]]. + RLP encoded as a list. + """ + + slot: StorageKey + changes: Tuple[StorageChange, ...] + + +@slotted_freezable +@dataclass +class AccountChanges: + """ + All changes for a single account, grouped by field type. + RLP encoded as: [address, storage_changes, storage_reads, + balance_changes, nonce_changes, code_changes]. + """ + + address: Address + + # slot -> [block_access_index -> new_value] + storage_changes: Tuple[SlotChanges, ...] + + # read-only storage keys + storage_reads: Tuple[StorageKey, ...] + + # [block_access_index -> post_balance] + balance_changes: Tuple[BalanceChange, ...] + + # [block_access_index -> new_nonce] + nonce_changes: Tuple[NonceChange, ...] + + # [block_access_index -> new_code] + code_changes: Tuple[CodeChange, ...] + + +@slotted_freezable +@dataclass +class BlockAccessList: + """ + Block-Level Access List for EIP-7928. + Contains all addresses accessed during block execution. + RLP encoded as a list of AccountChanges. + """ + + account_changes: Tuple[AccountChanges, ...] diff --git a/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py new file mode 100644 index 0000000000..bbcf4a3d21 --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py @@ -0,0 +1,232 @@ +""" +Utilities for working with Block Access Lists using RLP encoding, +as specified in EIP-7928. + +This module provides: + +- RLP encoding functions for all Block Access List types +- Hash computation using [`keccak256`] +- Validation logic to ensure structural correctness + +The encoding follows the RLP specification used throughout Ethereum. + +[`keccak256`]: ref:ethereum.crypto.hash.keccak256 +""" + +from typing import cast + +from ethereum_rlp import Extended, rlp +from ethereum_types.bytes import Bytes +from ethereum_types.numeric import Uint + +from ethereum.crypto.hash import Hash32, keccak256 + +from .builder import BlockAccessListBuilder +from .rlp_types import MAX_CODE_SIZE, MAX_TXS, BlockAccessList + + +def compute_block_access_list_hash( + block_access_list: BlockAccessList, +) -> Hash32: + """ + Compute the hash of a Block Access List. + + The Block Access List is RLP-encoded and then hashed with keccak256. + + Parameters + ---------- + block_access_list : + The Block Access List to hash. + + Returns + ------- + hash : + The keccak256 hash of the RLP-encoded Block Access List. + + """ + block_access_list_bytes = rlp_encode_block_access_list(block_access_list) + return keccak256(block_access_list_bytes) + + +def rlp_encode_block_access_list(block_access_list: BlockAccessList) -> Bytes: + """ + Encode a [`BlockAccessList`] to RLP bytes. + + This is the top-level encoding function that produces the final RLP + representation of a block's access list, following the updated EIP-7928 + specification. + + Parameters + ---------- + block_access_list : + The block access list to encode. + + Returns + ------- + encoded : + The complete RLP-encoded block access list. + + [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 + + """ + # Encode as a list of AccountChanges directly (not wrapped) + account_changes_list = [] + for account in block_access_list.account_changes: + # Each account is encoded as: + # [address, storage_changes, storage_reads, + # balance_changes, nonce_changes, code_changes] + storage_changes_list = [ + [ + slot_changes.slot, + [ + [Uint(c.block_access_index), c.new_value] + for c in slot_changes.changes + ], + ] + for slot_changes in account.storage_changes + ] + + storage_reads_list = list(account.storage_reads) + + balance_changes_list = [ + [Uint(bc.block_access_index), Uint(bc.post_balance)] + for bc in account.balance_changes + ] + + nonce_changes_list = [ + [Uint(nc.block_access_index), Uint(nc.new_nonce)] + for nc in account.nonce_changes + ] + + code_changes_list = [ + [Uint(cc.block_access_index), cc.new_code] + for cc in account.code_changes + ] + + account_changes_list.append( + [ + account.address, + storage_changes_list, + storage_reads_list, + balance_changes_list, + nonce_changes_list, + code_changes_list, + ] + ) + + encoded = rlp.encode(cast(Extended, account_changes_list)) + return Bytes(encoded) + + +def validate_block_access_list_against_execution( + block_access_list: BlockAccessList, + block_access_list_builder: BlockAccessListBuilder | None = None, +) -> bool: + """ + Validate that a Block Access List is structurally correct and + optionally matches a builder's state. + + Parameters + ---------- + block_access_list : + The Block Access List to validate. + block_access_list_builder : + Optional Block Access List builder to validate against. + If provided, checks that the + Block Access List hash matches what would be built from + the builder's current state. + + Returns + ------- + valid : + True if the Block Access List is structurally valid and + matches the builder (if provided). + + """ + # 1. Validate structural constraints + + # Check that storage changes and reads don't overlap for the same slot + for account in block_access_list.account_changes: + changed_slots = {sc.slot for sc in account.storage_changes} + read_slots = set(account.storage_reads) + + # A slot should not be in both changes and reads (per EIP-7928) + if changed_slots & read_slots: + return False + + # 2. Validate ordering (addresses should be sorted lexicographically) + addresses = [ + account.address for account in block_access_list.account_changes + ] + if addresses != sorted(addresses): + return False + + # 3. Validate all data is within bounds + max_block_access_index = ( + MAX_TXS + 1 + ) # 0 for pre-exec, 1..MAX_TXS for txs, MAX_TXS+1 for post-exec + for account in block_access_list.account_changes: + # Validate storage slots are sorted within each account + storage_slots = [sc.slot for sc in account.storage_changes] + if storage_slots != sorted(storage_slots): + return False + + # Check storage changes + for slot_changes in account.storage_changes: + # Check changes are sorted by block_access_index + indices = [c.block_access_index for c in slot_changes.changes] + if indices != sorted(indices): + return False + + for change in slot_changes.changes: + if int(change.block_access_index) > max_block_access_index: + return False + + # Check balance changes are sorted by block_access_index + balance_indices = [ + bc.block_access_index for bc in account.balance_changes + ] + if balance_indices != sorted(balance_indices): + return False + + for balance_change in account.balance_changes: + if int(balance_change.block_access_index) > max_block_access_index: + return False + + # Check nonce changes are sorted by block_access_index + nonce_indices = [nc.block_access_index for nc in account.nonce_changes] + if nonce_indices != sorted(nonce_indices): + return False + + for nonce_change in account.nonce_changes: + if int(nonce_change.block_access_index) > max_block_access_index: + return False + + # Check code changes are sorted by block_access_index + code_indices = [cc.block_access_index for cc in account.code_changes] + if code_indices != sorted(code_indices): + return False + + for code_change in account.code_changes: + if int(code_change.block_access_index) > max_block_access_index: + return False + if len(code_change.new_code) > MAX_CODE_SIZE: + return False + + # 4. If Block Access List builder provided, validate against it + # by comparing hashes + if block_access_list_builder is not None: + from .builder import build_block_access_list + + # Build a Block Access List from the builder + expected_block_access_list = build_block_access_list( + block_access_list_builder + ) + + # Compare hashes + if compute_block_access_list_hash( + block_access_list + ) != compute_block_access_list_hash(expected_block_access_list): + return False + + return True diff --git a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py new file mode 100644 index 0000000000..66a4f1ebbd --- /dev/null +++ b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py @@ -0,0 +1,667 @@ +""" +Provides state change tracking functionality for building Block +Access Lists during transaction execution. + +The tracker integrates with the EVM execution to capture all state accesses +and modifications, distinguishing between actual changes and no-op operations. +It maintains a cache of pre-state values to enable accurate change detection +throughout block execution. + +See [EIP-7928] for the full specification +[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, List, Set, Tuple + +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U64, U256, Uint + +from ..fork_types import Address +from .builder import ( + BlockAccessListBuilder, + add_balance_change, + add_code_change, + add_nonce_change, + add_storage_read, + add_storage_write, + add_touched_account, +) +from .rlp_types import BlockAccessIndex + +if TYPE_CHECKING: + from ..state import State # noqa: F401 + + +@dataclass +class CallFrameSnapshot: + """ + Snapshot of block access list state for a single call frame. + + Used to track changes within a call frame to enable proper handling + of reverts as specified in EIP-7928. + """ + + touched_addresses: Set[Address] = field(default_factory=set) + """Addresses touched during this call frame.""" + + storage_writes: Dict[Tuple[Address, Bytes32], U256] = field( + default_factory=dict + ) + """Storage writes made during this call frame.""" + + balance_changes: Set[Tuple[Address, BlockAccessIndex, U256]] = field( + default_factory=set + ) + """Balance changes made during this call frame.""" + + nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]] = field( + default_factory=set + ) + """Nonce changes made during this call frame.""" + + code_changes: Set[Tuple[Address, BlockAccessIndex, Bytes]] = field( + default_factory=set + ) + """Code changes made during this call frame.""" + + +@dataclass +class StateChangeTracker: + """ + Tracks state changes during transaction execution for Block Access List + construction. + + This tracker maintains a cache of pre-state values and coordinates with + the [`BlockAccessListBuilder`] to record all state changes made during + block execution. It ensures that only actual changes (not no-op writes) + are recorded in the access list. + + [`BlockAccessListBuilder`]: + ref:ethereum.forks.amsterdam.block_access_lists.builder.BlockAccessListBuilder + """ + + block_access_list_builder: BlockAccessListBuilder + """ + The builder instance that accumulates all tracked changes. + """ + + pre_storage_cache: Dict[tuple, U256] = field(default_factory=dict) + """ + Cache of pre-transaction storage values, keyed by (address, slot) tuples. + This cache is cleared at the start of each transaction to track values + from the beginning of the current transaction. + """ + + pre_balance_cache: Dict[Address, U256] = field(default_factory=dict) + """ + Cache of pre-transaction balance values, keyed by address. + This cache is cleared at the start of each transaction and used by + finalize_transaction_changes to filter out balance changes where + the final balance equals the initial balance. + """ + + current_block_access_index: Uint = Uint(0) + """ + The current block access index (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + """ + + call_frame_snapshots: List[CallFrameSnapshot] = field(default_factory=list) + """ + Stack of snapshots for nested call frames to handle reverts properly. + """ + + +def set_block_access_index( + tracker: StateChangeTracker, block_access_index: Uint +) -> None: + """ + Set the current block access index for tracking changes. + + Must be called before processing each transaction/system contract + to ensure changes are associated with the correct block access index. + + Note: Block access indices differ from transaction indices: + - 0: Pre-execution (system contracts like beacon roots, block hashes) + - 1..n: Transactions (tx at index i gets block_access_index i+1) + - n+1: Post-execution (withdrawals, requests) + + Parameters + ---------- + tracker : + The state change tracker instance. + block_access_index : + The block access index (0 for pre-execution, + 1..n for transactions, n+1 for post-execution). + + """ + tracker.current_block_access_index = block_access_index + # Clear the pre-storage cache for each new transaction to ensure + # no-op writes are detected relative to the transaction start + tracker.pre_storage_cache.clear() + # Clear the pre-balance cache for each new transaction + tracker.pre_balance_cache.clear() + + +def capture_pre_state( + tracker: StateChangeTracker, address: Address, key: Bytes32, state: "State" +) -> U256: + """ + Capture and cache the pre-transaction value for a storage location. + + Retrieves the storage value from the beginning of the current transaction. + The value is cached within the transaction to avoid repeated lookups and + to maintain consistency across multiple accesses within the same + transaction. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address containing the storage. + key : + The storage slot to read. + state : + The current execution state. + + Returns + ------- + value : + The storage value at the beginning of the current transaction. + + """ + cache_key = (address, key) + if cache_key not in tracker.pre_storage_cache: + # Import locally to avoid circular import + from ..state import get_storage + + tracker.pre_storage_cache[cache_key] = get_storage(state, address, key) + return tracker.pre_storage_cache[cache_key] + + +def track_address_access( + tracker: StateChangeTracker, address: Address +) -> None: + """ + Track that an address was accessed. + + Records account access even when no state changes occur. This is + important for operations that read account data without modifying it. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address that was accessed. + + """ + add_touched_account(tracker.block_access_list_builder, address) + + +def track_storage_read( + tracker: StateChangeTracker, address: Address, key: Bytes32, state: "State" +) -> None: + """ + Track a storage read operation. + + Records that a storage slot was read and captures its pre-state value. + The slot will only appear in the final access list if it wasn't also + written to during block execution. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose storage is being read. + key : + The storage slot being read. + state : + The current execution state. + + """ + track_address_access(tracker, address) + + capture_pre_state(tracker, address, key, state) + + add_storage_read(tracker.block_access_list_builder, address, key) + + +def track_storage_write( + tracker: StateChangeTracker, + address: Address, + key: Bytes32, + new_value: U256, + state: "State", +) -> None: + """ + Track a storage write operation. + + Records storage modifications, but only if the new value differs from + the pre-state value. No-op writes (where the value doesn't change) are + tracked as reads instead, as specified in [EIP-7928]. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose storage is being modified. + key : + The storage slot being written to. + new_value : + The new value to write. + state : + The current execution state. + + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + + """ + track_address_access(tracker, address) + + pre_value = capture_pre_state(tracker, address, key, state) + + value_bytes = new_value.to_be_bytes32() + + if pre_value != new_value: + add_storage_write( + tracker.block_access_list_builder, + address, + key, + BlockAccessIndex(tracker.current_block_access_index), + value_bytes, + ) + # Record in current call frame snapshot if exists + if tracker.call_frame_snapshots: + snapshot = tracker.call_frame_snapshots[-1] + snapshot.storage_writes[(address, key)] = new_value + else: + add_storage_read(tracker.block_access_list_builder, address, key) + + +def capture_pre_balance( + tracker: StateChangeTracker, address: Address, state: "State" +) -> U256: + """ + Capture and cache the pre-transaction balance for an account. + + This function caches the balance on first access for each address during + a transaction. It must be called before any balance modifications are made + to ensure we capture the pre-transaction balance correctly. The cache is + cleared at the beginning of each transaction. + + This is used by finalize_transaction_changes to determine which balance + changes should be filtered out. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address. + state : + The current execution state. + + Returns + ------- + value : + The balance at the beginning of the current transaction. + + """ + if address not in tracker.pre_balance_cache: + # Import locally to avoid circular import + from ..state import get_account + + # Cache the current balance on first access + # This should be called before any balance modifications + account = get_account(state, address) + tracker.pre_balance_cache[address] = account.balance + return tracker.pre_balance_cache[address] + + +def track_balance_change( + tracker: StateChangeTracker, + address: Address, + new_balance: U256, +) -> None: + """ + Track a balance change for an account. + + Records the new balance after any balance-affecting operation, including + transfers, gas payments, block rewards, and withdrawals. The balance is + encoded as a 16-byte value (uint128) which is sufficient for the total + ETH supply. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose balance changed. + new_balance : + The new balance value. + + """ + track_address_access(tracker, address) + + block_access_index = BlockAccessIndex(tracker.current_block_access_index) + add_balance_change( + tracker.block_access_list_builder, + address, + block_access_index, + new_balance, + ) + + # Record in current call frame snapshot if exists + if tracker.call_frame_snapshots: + snapshot = tracker.call_frame_snapshots[-1] + snapshot.balance_changes.add( + (address, block_access_index, new_balance) + ) + + +def track_nonce_change( + tracker: StateChangeTracker, address: Address, new_nonce: Uint +) -> None: + """ + Track a nonce change for an account. + + Records nonce increments for both EOAs (when sending transactions) and + contracts (when performing [`CREATE`] or [`CREATE2`] operations). Deployed + contracts also have their initial nonce tracked. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The account address whose nonce changed. + new_nonce : + The new nonce value. + state : + The current execution state. + + [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 + + """ + track_address_access(tracker, address) + block_access_index = BlockAccessIndex(tracker.current_block_access_index) + nonce_u64 = U64(new_nonce) + add_nonce_change( + tracker.block_access_list_builder, + address, + block_access_index, + nonce_u64, + ) + + # Record in current call frame snapshot if exists + if tracker.call_frame_snapshots: + snapshot = tracker.call_frame_snapshots[-1] + snapshot.nonce_changes.add((address, block_access_index, nonce_u64)) + + +def track_code_change( + tracker: StateChangeTracker, address: Address, new_code: Bytes +) -> None: + """ + Track a code change for contract deployment. + + Records new contract code deployments via [`CREATE`], [`CREATE2`], or + [`SETCODE`] operations. This function is called when contract bytecode + is deployed to an address. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The address receiving the contract code. + new_code : + The deployed contract bytecode. + + [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create + [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 + + """ + track_address_access(tracker, address) + block_access_index = BlockAccessIndex(tracker.current_block_access_index) + add_code_change( + tracker.block_access_list_builder, + address, + block_access_index, + new_code, + ) + + # Record in current call frame snapshot if exists + if tracker.call_frame_snapshots: + snapshot = tracker.call_frame_snapshots[-1] + snapshot.code_changes.add((address, block_access_index, new_code)) + + +def handle_in_transaction_selfdestruct( + tracker: StateChangeTracker, address: Address +) -> None: + """ + Handle an account that self-destructed in the same transaction it was + created. + + Per EIP-7928, accounts destroyed within their creation transaction must be + included as read-only with storage writes converted to reads. Nonce and + code changes from the current transaction are also removed. + + Note: Balance changes are handled separately by + finalize_transaction_changes. + + Parameters + ---------- + tracker : + The state change tracker instance. + address : + The address that self-destructed. + + """ + builder = tracker.block_access_list_builder + if address not in builder.accounts: + return + + account_data = builder.accounts[address] + current_index = tracker.current_block_access_index + + # Convert storage writes from current tx to reads + for slot in list(account_data.storage_changes.keys()): + account_data.storage_changes[slot] = [ + c + for c in account_data.storage_changes[slot] + if c.block_access_index != current_index + ] + if not account_data.storage_changes[slot]: + del account_data.storage_changes[slot] + account_data.storage_reads.add(slot) + + # Remove nonce and code changes from current transaction + account_data.nonce_changes = [ + c + for c in account_data.nonce_changes + if c.block_access_index != current_index + ] + account_data.code_changes = [ + c + for c in account_data.code_changes + if c.block_access_index != current_index + ] + + +def finalize_transaction_changes( + tracker: StateChangeTracker, state: "State" +) -> None: + """ + Finalize changes for the current transaction. + + This method is called at the end of each transaction execution to filter + out spurious balance changes. It removes all balance changes for addresses + where the post-transaction balance equals the pre-transaction balance. + + This is crucial for handling cases like: + - In-transaction self-destructs where an account with 0 balance is created + and destroyed, resulting in no net balance change + - Round-trip transfers where an account receives and sends equal amounts + + Only actual state changes are recorded in the Block Access List. + + Parameters + ---------- + tracker : + The state change tracker instance. + state : + The current execution state. + + """ + # Import locally to avoid circular import + from ..state import get_account + + builder = tracker.block_access_list_builder + current_index = tracker.current_block_access_index + + # Check each address that had balance changes in this transaction + for address in list(builder.accounts.keys()): + account_data = builder.accounts[address] + + # Get the pre-transaction balance + pre_balance = capture_pre_balance(tracker, address, state) + + # Get the current (post-transaction) balance + post_balance = get_account(state, address).balance + + # If pre-tx balance equals post-tx balance, remove all balance changes + # for this address in the current transaction + if pre_balance == post_balance: + # Filter out balance changes from the current transaction + account_data.balance_changes = [ + change + for change in account_data.balance_changes + if change.block_access_index != current_index + ] + + +def begin_call_frame(tracker: StateChangeTracker) -> None: + """ + Begin a new call frame for tracking reverts. + + Creates a new snapshot to track changes within this call frame. + This allows proper handling of reverts as specified in EIP-7928. + + Parameters + ---------- + tracker : + The state change tracker instance. + + """ + tracker.call_frame_snapshots.append(CallFrameSnapshot()) + + +def rollback_call_frame(tracker: StateChangeTracker) -> None: + """ + Rollback changes from the current call frame. + + When a call reverts, this function: + - Converts storage writes to reads + - Removes balance, nonce, and code changes + - Preserves touched addresses + + This implements EIP-7928 revert handling where reverted writes + become reads and addresses remain in the access list. + + Parameters + ---------- + tracker : + The state change tracker instance. + + """ + if not tracker.call_frame_snapshots: + return + + snapshot = tracker.call_frame_snapshots.pop() + builder = tracker.block_access_list_builder + + # Convert storage writes to reads + for (address, slot), _ in snapshot.storage_writes.items(): + # Remove the write from storage_changes + if address in builder.accounts: + account_data = builder.accounts[address] + if slot in account_data.storage_changes: + # Filter out changes from this call frame + account_data.storage_changes[slot] = [ + change + for change in account_data.storage_changes[slot] + if change.block_access_index + != tracker.current_block_access_index + ] + if not account_data.storage_changes[slot]: + del account_data.storage_changes[slot] + # Add as a read instead + account_data.storage_reads.add(slot) + + # Remove balance changes from this call frame + for address, block_access_index, new_balance in snapshot.balance_changes: + if address in builder.accounts: + account_data = builder.accounts[address] + # Filter out balance changes from this call frame + account_data.balance_changes = [ + change + for change in account_data.balance_changes + if not ( + change.block_access_index == block_access_index + and change.post_balance == new_balance + ) + ] + + # Remove nonce changes from this call frame + for address, block_access_index, new_nonce in snapshot.nonce_changes: + if address in builder.accounts: + account_data = builder.accounts[address] + # Filter out nonce changes from this call frame + account_data.nonce_changes = [ + change + for change in account_data.nonce_changes + if not ( + change.block_access_index == block_access_index + and change.new_nonce == new_nonce + ) + ] + + # Remove code changes from this call frame + for address, block_access_index, new_code in snapshot.code_changes: + if address in builder.accounts: + account_data = builder.accounts[address] + # Filter out code changes from this call frame + account_data.code_changes = [ + change + for change in account_data.code_changes + if not ( + change.block_access_index == block_access_index + and change.new_code == new_code + ) + ] + + # All touched addresses remain in the access list (already tracked) + + +def commit_call_frame(tracker: StateChangeTracker) -> None: + """ + Commit changes from the current call frame. + + Removes the current call frame snapshot without rolling back changes. + Called when a call completes successfully. + + Parameters + ---------- + tracker : + The state change tracker instance. + + """ + if tracker.call_frame_snapshots: + tracker.call_frame_snapshots.pop() diff --git a/src/ethereum/forks/amsterdam/blocks.py b/src/ethereum/forks/amsterdam/blocks.py index ba3c27e9e3..0d14066f47 100644 --- a/src/ethereum/forks/amsterdam/blocks.py +++ b/src/ethereum/forks/amsterdam/blocks.py @@ -19,6 +19,7 @@ from ethereum.crypto.hash import Hash32 +from .block_access_lists.rlp_types import BlockAccessList from .fork_types import Address, Bloom, Root from .transactions import ( AccessListTransaction, @@ -242,6 +243,16 @@ class Header: [SHA2-256]: https://en.wikipedia.org/wiki/SHA-2 """ + block_access_list_hash: Hash32 + """ + [SHA2-256] hash of the Block Access List containing all accounts and + storage locations accessed during block execution. Introduced in + [EIP-7928]. See [`compute_block_access_list_hash`][cbalh] for more + details. + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + [cbalh]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_utils.compute_block_access_list_hash # noqa: E501 + """ + @slotted_freezable @dataclass @@ -295,6 +306,13 @@ class Block: A tuple of withdrawals processed in this block. """ + block_access_list: BlockAccessList + """ + Block Access List containing all accounts and storage locations accessed + during block execution. Introduced in [EIP-7928]. + [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 + """ + @slotted_freezable @dataclass diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 1d8bbcc106..08f5ad734c 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -16,7 +16,7 @@ from ethereum_rlp import rlp from ethereum_types.bytes import Bytes -from ethereum_types.numeric import U64, U256, Uint +from ethereum_types.numeric import U64, U256, Uint, ulen from ethereum.crypto.hash import Hash32, keccak256 from ethereum.exceptions import ( @@ -29,6 +29,14 @@ ) from . import vm +from .block_access_lists.builder import build_block_access_list +from .block_access_lists.rlp_utils import compute_block_access_list_hash +from .block_access_lists.tracker import ( + finalize_transaction_changes, + handle_in_transaction_selfdestruct, + set_block_access_index, + track_balance_change, +) from .blocks import Block, Header, Log, Receipt, Withdrawal, encode_receipt from .bloom import logs_bloom from .exceptions import ( @@ -53,6 +61,7 @@ from .state import ( State, TransientStorage, + account_exists_and_is_empty, destroy_account, get_account, increment_nonce, @@ -246,6 +255,9 @@ def state_transition(chain: BlockChain, block: Block) -> None: block_logs_bloom = logs_bloom(block_output.block_logs) withdrawals_root = root(block_output.withdrawals_trie) requests_hash = compute_requests_hash(block_output.requests) + computed_block_access_list_hash = compute_block_access_list_hash( + block_output.block_access_list + ) if block_output.block_gas_used != block.header.gas_used: raise InvalidBlock( @@ -265,6 +277,8 @@ def state_transition(chain: BlockChain, block: Block) -> None: raise InvalidBlock if requests_hash != block.header.requests_hash: raise InvalidBlock + if computed_block_access_list_hash != block.header.block_access_list_hash: + raise InvalidBlock("Invalid block access list hash") chain.blocks.append(block) if len(chain.blocks) > 255: @@ -764,6 +778,10 @@ def apply_body( """ block_output = vm.BlockOutput() + # Set block access index for pre-execution system contracts + # EIP-7928: System contracts use block_access_index 0 + set_block_access_index(block_env.state.change_tracker, Uint(0)) + process_unchecked_system_transaction( block_env=block_env, target_address=BEACON_ROOTS_ADDRESS, @@ -779,12 +797,21 @@ def apply_body( for i, tx in enumerate(map(decode_transaction, transactions)): process_transaction(block_env, block_output, tx, Uint(i)) + # EIP-7928: Post-execution uses block_access_index len(transactions) + 1 + post_execution_index = ulen(transactions) + Uint(1) + set_block_access_index( + block_env.state.change_tracker, post_execution_index + ) + process_withdrawals(block_env, block_output, withdrawals) process_general_purpose_requests( block_env=block_env, block_output=block_output, ) + block_output.block_access_list = build_block_access_list( + block_env.state.change_tracker.block_access_list_builder + ) return block_output @@ -864,6 +891,10 @@ def process_transaction( Index of the transaction in the block. """ + # EIP-7928: Transactions use block_access_index 1 to len(transactions) + # Transaction at index i gets block_access_index i+1 + set_block_access_index(block_env.state.change_tracker, index + Uint(1)) + trie_set( block_output.transactions_trie, rlp.encode(index), @@ -971,15 +1002,35 @@ def process_transaction( coinbase_balance_after_mining_fee = get_account( block_env.state, block_env.coinbase ).balance + U256(transaction_fee) + + # Always set coinbase balance to ensure proper tracking set_account_balance( block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee, ) + if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( + block_env.state, block_env.coinbase + ): + destroy_account(block_env.state, block_env.coinbase) + for address in tx_output.accounts_to_delete: + # EIP-7928: In-transaction self-destruct - convert storage writes to + # reads and remove nonce/code changes. Only accounts created in same + # tx are in accounts_to_delete per EIP-6780. + handle_in_transaction_selfdestruct( + block_env.state.change_tracker, address + ) destroy_account(block_env.state, address) + # EIP-7928: Finalize transaction changes + # Remove balance changes where post-tx balance equals pre-tx balance + finalize_transaction_changes( + block_env.state.change_tracker, + block_env.state, + ) + block_output.block_gas_used += tx_gas_used_after_refund block_output.blob_gas_used += tx_blob_gas_used @@ -1020,6 +1071,16 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(block_env.state, wd.address, increase_recipient_balance) + # Track balance change for BAL + # (withdrawals are tracked as system contract changes) + new_balance = get_account(block_env.state, wd.address).balance + track_balance_change( + block_env.state.change_tracker, wd.address, U256(new_balance) + ) + + if account_exists_and_is_empty(block_env.state, wd.address): + destroy_account(block_env.state, wd.address) + def check_gas_limit(gas_limit: Uint, parent_gas_limit: Uint) -> bool: """ diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index e997411f6d..3067b175d6 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -23,6 +23,14 @@ from ethereum_types.frozen import modify from ethereum_types.numeric import U256, Uint +from .block_access_lists.builder import BlockAccessListBuilder +from .block_access_lists.tracker import ( + StateChangeTracker, + capture_pre_balance, + track_balance_change, + track_code_change, + track_nonce_change, +) from .fork_types import EMPTY_ACCOUNT, Account, Address, Root from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set @@ -46,6 +54,9 @@ class State: ] ] = field(default_factory=list) created_accounts: Set[Address] = field(default_factory=set) + change_tracker: StateChangeTracker = field( + default_factory=lambda: StateChangeTracker(BlockAccessListBuilder()) + ) @dataclass @@ -440,6 +451,34 @@ def account_has_storage(state: State, address: Address) -> bool: return address in state._storage_tries +def account_exists_and_is_empty(state: State, address: Address) -> bool: + """ + Checks if an account exists and has zero nonce, empty code and zero + balance. + + Parameters + ---------- + state: + The state + address: + Address of the account that needs to be checked. + + Returns + ------- + exists_and_is_empty : `bool` + True if an account exists and has zero nonce, empty code and zero + balance, False otherwise. + + """ + account = get_account_optional(state, address) + return ( + account is not None + and account.nonce == Uint(0) + and account.code == b"" + and account.balance == 0 + ) + + def is_account_alive(state: State, address: Address) -> bool: """ Check whether an account is both in the state and non-empty. @@ -469,16 +508,7 @@ def modify_state( exists and has zero nonce, empty code, and zero balance, it is destroyed. """ set_account(state, address, modify(get_account(state, address), f)) - - account = get_account_optional(state, address) - account_exists_and_is_empty = ( - account is not None - and account.nonce == Uint(0) - and account.code == b"" - and account.balance == 0 - ) - - if account_exists_and_is_empty: + if account_exists_and_is_empty(state, address): destroy_account(state, address) @@ -491,6 +521,9 @@ def move_ether( """ Move funds between accounts. """ + # Capture pre-transaction balance before first modification + capture_pre_balance(state.change_tracker, sender_address, state) + capture_pre_balance(state.change_tracker, recipient_address, state) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -503,6 +536,16 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(state, sender_address, reduce_sender_balance) modify_state(state, recipient_address, increase_recipient_balance) + sender_new_balance = get_account(state, sender_address).balance + recipient_new_balance = get_account(state, recipient_address).balance + + track_balance_change( + state.change_tracker, sender_address, U256(sender_new_balance) + ) + track_balance_change( + state.change_tracker, recipient_address, U256(recipient_new_balance) + ) + def set_account_balance(state: State, address: Address, amount: U256) -> None: """ @@ -520,12 +563,16 @@ def set_account_balance(state: State, address: Address, amount: U256) -> None: The amount that needs to set in balance. """ + # Capture pre-transaction balance before first modification + capture_pre_balance(state.change_tracker, address, state) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) + track_balance_change(state.change_tracker, address, amount) + def increment_nonce(state: State, address: Address) -> None: """ @@ -546,6 +593,16 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) + # Track nonce change for Block Access List + # (for ALL accounts and ALL nonce changes) + # This includes: + # - EOA senders (transaction nonce increments) + # - Contracts performing CREATE/CREATE2 + # - Deployed contracts + # - EIP-7702 authorities + account = get_account(state, address) + track_nonce_change(state.change_tracker, address, account.nonce) + def set_code(state: State, address: Address, code: Bytes) -> None: """ @@ -569,6 +626,13 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) + # Only track code changes if it's not setting empty code on a + # newly created address. For newly created addresses, setting + # code to b"" is not a meaningful state change since the address + # had no code to begin with. + if not (code == b"" and address in state.created_accounts): + track_code_change(state.change_tracker, address, code) + def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: """ diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index b2a8c5e2b9..7c2db77ce9 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -21,6 +21,7 @@ from ethereum.crypto.hash import Hash32 from ethereum.exceptions import EthereumException +from ..block_access_lists.rlp_types import BlockAccessList from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Address, Authorization, VersionedHash from ..state import State, TransientStorage @@ -73,6 +74,8 @@ class BlockOutput: Total blob gas used in the block. requests : `Bytes` Hash of all the requests in the block. + block_access_list: `BlockAccessList` + The block access list for the block. """ block_gas_used: Uint = Uint(0) @@ -89,6 +92,9 @@ class BlockOutput: ) blob_gas_used: U64 = U64(0) requests: List[Bytes] = field(default_factory=list) + block_access_list: BlockAccessList = field( + default_factory=lambda: BlockAccessList(account_changes=()) + ) @dataclass diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 29909b5fa5..eca5978435 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -12,6 +12,7 @@ from ethereum.crypto.hash import keccak256 from ethereum.exceptions import InvalidBlock, InvalidSignatureError +from ..block_access_lists.tracker import track_address_access from ..fork_types import Address, Authorization from ..state import account_exists, get_account, increment_nonce, set_code from ..utils.hexadecimal import hex_to_address @@ -134,10 +135,14 @@ def access_delegation( """ state = evm.message.block_env.state + code = get_account(state, address).code if not is_valid_delegation(code): return False, address, code, Uint(0) + # EIP-7928: Track the authority address (delegated account being called) + track_address_access(state.change_tracker, address) + address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) if address in evm.accessed_addresses: access_gas_cost = GAS_WARM_ACCESS @@ -146,6 +151,9 @@ def access_delegation( access_gas_cost = GAS_COLD_ACCOUNT_ACCESS code = get_account(state, address).code + # EIP-7928: Track delegation target when loaded as call target + track_address_access(state.change_tracker, address) + return True, address, code, access_gas_cost @@ -185,6 +193,10 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code + # EIP-7928: Track authority account access in BAL even if delegation + # fails + track_address_access(state.change_tracker, authority) + if authority_code and not is_valid_delegation(authority_code): continue diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 8369043465..39b89567ff 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -17,6 +17,7 @@ from ethereum.crypto.hash import keccak256 from ethereum.utils.numeric import ceil32 +from ...block_access_lists.tracker import track_address_access from ...fork_types import EMPTY_ACCOUNT from ...state import get_account from ...utils.address import to_address_masked @@ -84,7 +85,9 @@ def balance(evm: Evm) -> None: # OPERATION # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. - balance = get_account(evm.message.block_env.state, address).balance + state = evm.message.block_env.state + balance = get_account(state, address).balance + track_address_access(state.change_tracker, address) push(evm.stack, balance) @@ -350,7 +353,9 @@ def extcodesize(evm: Evm) -> None: charge_gas(evm, access_gas_cost) # OPERATION - code = get_account(evm.message.block_env.state, address).code + state = evm.message.block_env.state + code = get_account(state, address).code + track_address_access(state.change_tracker, address) codesize = U256(len(code)) push(evm.stack, codesize) @@ -392,7 +397,9 @@ def extcodecopy(evm: Evm) -> None: # OPERATION evm.memory += b"\x00" * extend_memory.expand_by - code = get_account(evm.message.block_env.state, address).code + state = evm.message.block_env.state + code = get_account(state, address).code + track_address_access(state.change_tracker, address) value = buffer_read(code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) @@ -482,7 +489,9 @@ def extcodehash(evm: Evm) -> None: charge_gas(evm, access_gas_cost) # OPERATION - account = get_account(evm.message.block_env.state, address) + state = evm.message.block_env.state + account = get_account(state, address) + track_address_access(state.change_tracker, address) if account == EMPTY_ACCOUNT: codehash = U256(0) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index e6777c30a0..35ff36bab3 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -13,6 +13,10 @@ from ethereum_types.numeric import Uint +from ...block_access_lists.tracker import ( + track_storage_read, + track_storage_write, +) from ...state import ( get_storage, get_storage_original, @@ -56,8 +60,14 @@ def sload(evm: Evm) -> None: charge_gas(evm, GAS_COLD_SLOAD) # OPERATION - value = get_storage( - evm.message.block_env.state, evm.message.current_target, key + state = evm.message.block_env.state + value = get_storage(state, evm.message.current_target, key) + + track_storage_read( + state.change_tracker, + evm.message.current_target, + key, + evm.message.block_env.state, ) push(evm.stack, value) @@ -88,6 +98,15 @@ def sstore(evm: Evm) -> None: ) current_value = get_storage(state, evm.message.current_target, key) + # Track the implicit SLOAD that occurs in SSTORE + # This must happen BEFORE charge_gas() so reads are recorded even if OOG + track_storage_read( + state.change_tracker, + evm.message.current_target, + key, + evm.message.block_env.state, + ) + gas_cost = Uint(0) if (evm.message.current_target, key) not in evm.accessed_storage_keys: @@ -126,6 +145,19 @@ def sstore(evm: Evm) -> None: charge_gas(evm, gas_cost) if evm.message.is_static: raise WriteInStaticContext + + # Track storage write BEFORE modifying state + # so we capture the correct pre-value + + track_storage_write( + state.change_tracker, + evm.message.current_target, + key, + new_value, + state, + ) + + # Now modify the storage set_storage(state, evm.message.current_target, key, new_value) # PROGRAM COUNTER diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index fea7a0c1b9..665a6048c7 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -16,6 +16,7 @@ from ethereum.utils.numeric import ceil32 +from ...block_access_lists.tracker import track_address_access from ...fork_types import Address from ...state import ( account_has_code_or_nonce, @@ -77,6 +78,8 @@ def generic_create( process_create_message, ) + state = evm.message.block_env.state + call_data = memory_read_bytes( evm.memory, memory_start_position, memory_size ) @@ -90,7 +93,7 @@ def generic_create( evm.return_data = b"" sender_address = evm.message.current_target - sender = get_account(evm.message.block_env.state, sender_address) + sender = get_account(state, sender_address) if ( sender.balance < endowment @@ -104,15 +107,19 @@ def generic_create( evm.accessed_addresses.add(contract_address) if account_has_code_or_nonce( - evm.message.block_env.state, contract_address - ) or account_has_storage(evm.message.block_env.state, contract_address): + state, contract_address + ) or account_has_storage(state, contract_address): increment_nonce( - evm.message.block_env.state, evm.message.current_target + state, + evm.message.current_target, ) push(evm.stack, U256(0)) return - increment_nonce(evm.message.block_env.state, evm.message.current_target) + increment_nonce( + state, + evm.message.current_target, + ) child_message = Message( block_env=evm.message.block_env, @@ -133,6 +140,9 @@ def generic_create( disable_precompiles=False, parent_evm=evm, ) + + track_address_access(state.change_tracker, contract_address) + child_evm = process_create_message(child_message) if child_evm.error: @@ -326,6 +336,9 @@ def generic_call( disable_precompiles=disable_precompiles, parent_evm=evm, ) + + track_address_access(evm.message.block_env.state.change_tracker, to) + child_evm = process_message(child_message) if child_evm.error: @@ -486,6 +499,10 @@ def callcode(evm: Evm) -> None: ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) + track_address_access( + evm.message.block_env.state.change_tracker, code_address + ) + # OPERATION evm.memory += b"\x00" * extend_memory.expand_by sender_balance = get_account( @@ -566,7 +583,11 @@ def selfdestruct(evm: Evm) -> None: if originator in evm.message.block_env.state.created_accounts: # If beneficiary is the same as originator, then # the ether is burnt. - set_account_balance(evm.message.block_env.state, originator, U256(0)) + set_account_balance( + evm.message.block_env.state, + originator, + U256(0), + ) evm.accounts_to_delete.add(originator) # HALT the execution @@ -622,6 +643,10 @@ def delegatecall(evm: Evm) -> None: ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) + track_address_access( + evm.message.block_env.state.change_tracker, code_address + ) + # OPERATION evm.memory += b"\x00" * extend_memory.expand_by generic_call( diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 07e9f1d2db..afd66169f1 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -29,6 +29,12 @@ evm_trace, ) +from ..block_access_lists.tracker import ( + begin_call_frame, + commit_call_frame, + rollback_call_frame, + track_address_access, +) from ..blocks import Log from ..fork_types import Address from ..state import ( @@ -133,6 +139,12 @@ def process_message_call(message: Message) -> MessageCallOutput: message.code = get_account(block_env.state, delegated_address).code message.code_address = delegated_address + # EIP-7928: Track delegation target when loaded as call target + track_address_access( + block_env.state.change_tracker, + delegated_address, + ) + evm = process_message(message) if evm.error: @@ -241,6 +253,11 @@ def process_message(message: Message) -> Evm: # take snapshot of state before processing the message begin_transaction(state, transient_storage) + if hasattr(state, "change_tracker") and state.change_tracker: + begin_call_frame(state.change_tracker) + # Track target address access when processing a message + track_address_access(state.change_tracker, message.current_target) + if message.should_transfer_value and message.value != 0: move_ether( state, message.caller, message.current_target, message.value @@ -251,8 +268,12 @@ def process_message(message: Message) -> Evm: # revert state to the last saved checkpoint # since the message call resulted in an error rollback_transaction(state, transient_storage) + if hasattr(state, "change_tracker") and state.change_tracker: + rollback_call_frame(state.change_tracker) else: commit_transaction(state, transient_storage) + if hasattr(state, "change_tracker") and state.change_tracker: + commit_call_frame(state.change_tracker) return evm diff --git a/src/ethereum/forks/osaka/vm/eoa_delegation.py b/src/ethereum/forks/osaka/vm/eoa_delegation.py index 29909b5fa5..0913fa63ff 100644 --- a/src/ethereum/forks/osaka/vm/eoa_delegation.py +++ b/src/ethereum/forks/osaka/vm/eoa_delegation.py @@ -134,6 +134,7 @@ def access_delegation( """ state = evm.message.block_env.state + code = get_account(state, address).code if not is_valid_delegation(code): return False, address, code, Uint(0) diff --git a/src/ethereum/genesis.py b/src/ethereum/genesis.py index 84519271dc..7ba79d6c26 100644 --- a/src/ethereum/genesis.py +++ b/src/ethereum/genesis.py @@ -259,6 +259,9 @@ def add_genesis_block( if has_field(hardfork.Header, "requests_hash"): fields["requests_hash"] = Hash32(b"\0" * 32) + if has_field(hardfork.Header, "block_access_list_hash"): + fields["block_access_list_hash"] = Hash32(b"\0" * 32) + genesis_header = hardfork.Header(**fields) block_fields = { @@ -273,6 +276,9 @@ def add_genesis_block( if has_field(hardfork.Block, "requests"): block_fields["requests"] = () + if has_field(hardfork.Block, "block_access_list"): + block_fields["block_access_list"] = rlp.encode([]) + genesis_block = hardfork.Block(**block_fields) chain.blocks.append(genesis_block) diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 5e085504cd..2761ed6e66 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -120,6 +120,23 @@ def has_signing_hash_155(self) -> bool: """Check if the fork has a `signing_hash_155` function.""" return hasattr(self._module("transactions"), "signing_hash_155") + @property + def build_block_access_list(self) -> Any: + """Build function of the fork.""" + return self._module("block_access_lists").build_block_access_list + + @property + def compute_block_access_list_hash(self) -> Any: + """compute_block_access_list_hash function of the fork.""" + return self._module( + "block_access_lists" + ).compute_block_access_list_hash + + @property + def set_block_access_index(self) -> Any: + """set_block_access_index function of the fork.""" + return self._module("block_access_lists").set_block_access_index + @property def signing_hash_2930(self) -> Any: """signing_hash_2930 function of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index fc09dfd8a3..3032572afc 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -10,7 +10,7 @@ from typing import Any, Final, TextIO, Tuple, Type, TypeVar from ethereum_rlp import rlp -from ethereum_types.numeric import U64, U256, Uint +from ethereum_types.numeric import U64, U256, Uint, ulen from typing_extensions import override from ethereum import trace @@ -372,6 +372,10 @@ def run_state_test(self) -> Any: self.result.rejected = self.txs.rejected_txs def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: + if self.fork.is_after_fork("amsterdam"): + self.fork.set_block_access_index( + block_env.state.change_tracker, Uint(0) + ) if self.fork.has_compute_requests_hash: self.fork.process_unchecked_system_transaction( block_env=block_env, @@ -386,20 +390,38 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: data=block_env.parent_beacon_block_root, ) - for i, tx in zip( - self.txs.successfully_parsed, - self.txs.transactions, - strict=True, + for tx_index, (original_idx, tx) in enumerate( + zip( + self.txs.successfully_parsed, + self.txs.transactions, + strict=True, + ) ): self.backup_state() try: self.fork.process_transaction( - block_env, block_output, tx, Uint(i) + block_env, block_output, tx, Uint(tx_index) ) except EthereumException as e: - self.txs.rejected_txs[i] = f"Failed transaction: {e!r}" + self.txs.rejected_txs[original_idx] = ( + f"Failed transaction: {e!r}" + ) self.restore_state() - self.logger.warning(f"Transaction {i} failed: {e!r}") + self.logger.warning( + f"Transaction {original_idx} failed: {e!r}" + ) + + if self.fork.is_after_fork("amsterdam"): + assert block_env.state.change_tracker is not None + num_transactions = ulen( + [tx for tx in self.txs.successfully_parsed if tx] + ) + + # post-execution use n + 1 + post_execution_index = num_transactions + Uint(1) + self.fork.set_block_access_index( + block_env.state.change_tracker, post_execution_index + ) if not self.fork.proof_of_stake: if self.options.state_reward is None: @@ -417,6 +439,11 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: if self.fork.has_compute_requests_hash: self.fork.process_general_purpose_requests(block_env, block_output) + if self.fork.is_after_fork("amsterdam"): + block_output.block_access_list = self.fork.build_block_access_list( + block_env.state.change_tracker.block_access_list_builder + ) + def run_blockchain_test(self) -> None: """ Apply a block on the pre-state. Also includes system operations. diff --git a/src/ethereum_spec_tools/evm_tools/t8n/env.py b/src/ethereum_spec_tools/evm_tools/t8n/env.py index 07a3071c1f..8cadd58c9f 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/env.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/env.py @@ -145,6 +145,9 @@ def read_excess_blob_gas(self, data: Any, t8n: "T8N") -> None: if t8n.fork.has_compute_requests_hash: arguments["requests_hash"] = Hash32(b"\0" * 32) + if t8n.fork.is_after_fork("amsterdam"): + arguments["block_access_list_hash"] = Hash32(b"\0" * 32) + parent_header = t8n.fork.Header(**arguments) self.excess_blob_gas = t8n.fork.calculate_excess_blob_gas( diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index c60c266965..1aac5e4b3f 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -268,6 +268,8 @@ class Result: requests_hash: Optional[Hash32] = None requests: Optional[List[Bytes]] = None block_exception: Optional[str] = None + block_access_list: Optional[Any] = None + block_access_list_hash: Optional[Hash32] = None def get_receipts_from_output( self, @@ -323,6 +325,87 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: self.requests = block_output.requests self.requests_hash = t8n.fork.compute_requests_hash(self.requests) + if hasattr(block_output, "block_access_list"): + self.block_access_list = block_output.block_access_list + self.block_access_list_hash = ( + t8n.fork.compute_block_access_list_hash( + block_output.block_access_list + ) + ) + + def _block_access_list_to_json(self, bal: Any) -> Any: + """ + Convert BlockAccessList to JSON format matching the Pydantic models. + """ + account_changes = [] + + for account in bal.account_changes: + account_data: Dict[str, Any] = { + "address": "0x" + account.address.hex() + } + + # Add storage changes if present + if account.storage_changes: + storage_changes = [] + for slot_change in account.storage_changes: + slot_data: Dict[str, Any] = { + "slot": int.from_bytes(slot_change.slot, "big"), + "slotChanges": [], + } + for change in slot_change.changes: + slot_data["slotChanges"].append( + { + "txIndex": int(change.block_access_index), + "postValue": int.from_bytes( + change.new_value, "big" + ), + } + ) + storage_changes.append(slot_data) + account_data["storageChanges"] = storage_changes + + # Add storage reads if present + if account.storage_reads: + account_data["storageReads"] = [ + int.from_bytes(slot, "big") + for slot in account.storage_reads + ] + + # Add balance changes if present + if account.balance_changes: + account_data["balanceChanges"] = [ + { + "txIndex": int(change.block_access_index), + "postBalance": int(change.post_balance), + } + for change in account.balance_changes + ] + + # Add nonce changes if present + if account.nonce_changes: + account_data["nonceChanges"] = [ + { + "txIndex": int(change.block_access_index), + "postNonce": int(change.new_nonce), + } + for change in account.nonce_changes + ] + + # Add code changes if present + if account.code_changes: + account_data["codeChanges"] = [ + { + "txIndex": int(change.block_access_index), + "newCode": "0x" + change.new_code.hex(), + } + for change in account.code_changes + ] + + account_changes.append(account_data) + + # return as list directly + return account_changes + def json_encode_receipts(self) -> Any: """ Encode receipts to JSON. @@ -390,4 +473,15 @@ def to_json(self) -> Any: if self.block_exception is not None: data["blockException"] = self.block_exception + if self.block_access_list is not None: + # Convert BAL to JSON format + data["blockAccessList"] = self._block_access_list_to_json( + self.block_access_list + ) + + if self.block_access_list_hash is not None: + data["blockAccessListHash"] = encode_to_hex( + self.block_access_list_hash + ) + return data diff --git a/whitelist.txt b/whitelist.txt index 4f73730fd5..0d4f493ffc 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -1335,4 +1335,10 @@ ZeroPaddedHexNumber zfill zkevm Zsh -zsh \ No newline at end of file +zsh +slot1 +slot2 +lexicographically +uint16 +uint128 +630m From dfce10d63ecf93b7335bdc2c2d6d9fdb2c36e7b0 Mon Sep 17 00:00:00 2001 From: felipe Date: Thu, 30 Oct 2025 07:42:56 -0600 Subject: [PATCH 04/51] fix(specs): Fix zero value withdrawals BAL tracking (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(specs): Fix zero value withdrawals BAL tracking * docs(specs): rename 'finalize' to 'normalize' in comments * docs(specs): remove reference to uint128 for balance tracking --------- Co-authored-by: Toni Wahrstätter <51536394+nerolation@users.noreply.github.com> Co-authored-by: Toni Wahrstätter --- .../amsterdam/block_access_lists/tracker.py | 25 ++++++++++--------- src/ethereum/forks/amsterdam/fork.py | 21 +++++++++++++--- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py index 66a4f1ebbd..0ea945e7b1 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py @@ -97,7 +97,7 @@ class StateChangeTracker: """ Cache of pre-transaction balance values, keyed by address. This cache is cleared at the start of each transaction and used by - finalize_transaction_changes to filter out balance changes where + normalize_balance_changes to filter out balance changes where the final balance equals the initial balance. """ @@ -293,7 +293,7 @@ def capture_pre_balance( to ensure we capture the pre-transaction balance correctly. The cache is cleared at the beginning of each transaction. - This is used by finalize_transaction_changes to determine which balance + This is used by normalize_balance_changes to determine which balance changes should be filtered out. Parameters @@ -331,9 +331,7 @@ def track_balance_change( Track a balance change for an account. Records the new balance after any balance-affecting operation, including - transfers, gas payments, block rewards, and withdrawals. The balance is - encoded as a 16-byte value (uint128) which is sufficient for the total - ETH supply. + transfers, gas payments, block rewards, and withdrawals. Parameters ---------- @@ -454,7 +452,7 @@ def handle_in_transaction_selfdestruct( code changes from the current transaction are also removed. Note: Balance changes are handled separately by - finalize_transaction_changes. + normalize_balance_changes. Parameters ---------- @@ -495,22 +493,25 @@ def handle_in_transaction_selfdestruct( ] -def finalize_transaction_changes( +def normalize_balance_changes( tracker: StateChangeTracker, state: "State" ) -> None: """ - Finalize changes for the current transaction. + Normalize balance changes for the current block access index. - This method is called at the end of each transaction execution to filter - out spurious balance changes. It removes all balance changes for addresses - where the post-transaction balance equals the pre-transaction balance. + This method filters out spurious balance changes by removing all balance + changes for addresses where the post-execution balance equals the + pre-execution balance. This is crucial for handling cases like: - In-transaction self-destructs where an account with 0 balance is created and destroyed, resulting in no net balance change - Round-trip transfers where an account receives and sends equal amounts + - Zero-amount withdrawals where the balance doesn't actually change - Only actual state changes are recorded in the Block Access List. + This should be called at the end of any operation that tracks balance + changes (transactions, withdrawals, etc.). Only actual state changes are + recorded in the Block Access List. Parameters ---------- diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 08f5ad734c..c326bd1b93 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -32,8 +32,9 @@ from .block_access_lists.builder import build_block_access_list from .block_access_lists.rlp_utils import compute_block_access_list_hash from .block_access_lists.tracker import ( - finalize_transaction_changes, + capture_pre_balance, handle_in_transaction_selfdestruct, + normalize_balance_changes, set_block_access_index, track_balance_change, ) @@ -1024,9 +1025,9 @@ def process_transaction( ) destroy_account(block_env.state, address) - # EIP-7928: Finalize transaction changes + # EIP-7928: Normalize balance changes for this transaction # Remove balance changes where post-tx balance equals pre-tx balance - finalize_transaction_changes( + normalize_balance_changes( block_env.state.change_tracker, block_env.state, ) @@ -1069,6 +1070,12 @@ def increase_recipient_balance(recipient: Account) -> None: rlp.encode(wd), ) + # Capture pre-balance before modification (even for zero withdrawals) + # This ensures the address appears in BAL per EIP-7928 + capture_pre_balance( + block_env.state.change_tracker, wd.address, block_env.state + ) + modify_state(block_env.state, wd.address, increase_recipient_balance) # Track balance change for BAL @@ -1078,6 +1085,14 @@ def increase_recipient_balance(recipient: Account) -> None: block_env.state.change_tracker, wd.address, U256(new_balance) ) + # EIP-7928: Normalize balance changes for this withdrawal + # Remove balance changes where post-withdrawal balance + # equals pre-withdrawal balance + normalize_balance_changes( + block_env.state.change_tracker, + block_env.state, + ) + if account_exists_and_is_empty(block_env.state, wd.address): destroy_account(block_env.state, wd.address) From 468e75ed7eace9856edd915d0cca2025890ea5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= <51536394+nerolation@users.noreply.github.com> Date: Thu, 30 Oct 2025 21:26:25 +0100 Subject: [PATCH 05/51] fix(specs): static upfront check for create + selfdestruct (#22) --- .../forks/amsterdam/vm/instructions/system.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 665a6048c7..c9feaabbc7 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -78,18 +78,22 @@ def generic_create( process_create_message, ) + # Check static context first + if evm.message.is_static: + raise WriteInStaticContext + + # Check max init code size early before memory read + if memory_size > U256(MAX_INIT_CODE_SIZE): + raise OutOfGasError + state = evm.message.block_env.state call_data = memory_read_bytes( evm.memory, memory_start_position, memory_size ) - if len(call_data) > MAX_INIT_CODE_SIZE: - raise OutOfGasError create_message_gas = max_message_call_gas(Uint(evm.gas_left)) evm.gas_left -= create_message_gas - if evm.message.is_static: - raise WriteInStaticContext evm.return_data = b"" sender_address = evm.message.current_target @@ -544,6 +548,9 @@ def selfdestruct(evm: Evm) -> None: The current EVM frame. """ + if evm.message.is_static: + raise WriteInStaticContext + # STACK beneficiary = to_address_masked(pop(evm.stack)) @@ -563,8 +570,6 @@ def selfdestruct(evm: Evm) -> None: gas_cost += GAS_SELF_DESTRUCT_NEW_ACCOUNT charge_gas(evm, gas_cost) - if evm.message.is_static: - raise WriteInStaticContext originator = evm.message.current_target originator_balance = get_account( From c6df0b6bd07368a5e940e5214358bf464a98b687 Mon Sep 17 00:00:00 2001 From: raxhvl Date: Wed, 29 Oct 2025 10:37:49 +0100 Subject: [PATCH 06/51] feat(tests): Implement more EIP-7928 tests refactor(tests): Prevent skips by splitting tests appropriately fix(tests): Use valid inputs to precompile tests chore(tests): linting fixes feat(tests): EIP-7928 test_bal_storage_write_read_cross_frame feat(tests): EIP-7928 test_bal_storage_write_read_same_frame feat(tests): EIP-7928 test_bal_nonexistent_account_access feat(tests): EIP-7928 test_bal_nonexistent_value_transfer feat(tests): EIP-7928 test_bal_precompiles feat(tests): EIP-7928 test_bal_withdrawal_to_coinbase_empty_block feat(tests): EIP-7928 test_bal_withdrawal_to_coinbase feat(tests): EIP-7928 test_bal_withdrawal_largest_amount feat(tests): EIP-7928 test_bal_withdrawal_to_precompiles fix(tests): expectation for nonexistent account in post fix(specs,tests): Fix withdrawal tests for BALs issue with idx==0 - `self.txs.successfully_parsed` is a list of transaction indexes, not transactions. The "if tx" check here would then check `if 0` which parses as a boolean ``False``. This means we would skip counting the tx if index=0 was successful. - Fixes some test expectations where `post_code` was being checked instead of ``new_code``. feat(tests): EIP-7928 test_bal_zero_withdrawal feat(tests): EIP-7928 test_bal_withdrawal_and_new_contract feat(tests): EIP-7928 test_bal_withdrawal_and_selfdestruct feat(tests): EIP-7928 test_bal_multiple_withdrawals_same_address feat(tests): EIP-7928 withdrawal_and_value_transfer_same_address feat(tests): EIP-7928 withdrawal_and_state_access_same_account feat(tests): EIP-7928 test_bal_withdrawal_no_evm_execution feat(tests): EIP-7928 test_bal_withdrawal_to_nonexistent_account feat(tests): EIP-7928 test_bal_withdrawal_empty_block feat(tests): EIP-7928 test_bal_withdrawal_with_transaction feat(tests): EIP-7928 coinbase --- .../evm_tools/t8n/__init__.py | 6 +- .../test_block_access_lists.py | 508 +++++++++++ .../test_block_access_lists_eip4895.py | 810 ++++++++++++++++++ .../test_block_access_lists_opcodes.py | 148 ++++ .../test_cases.md | 27 +- 5 files changed, 1494 insertions(+), 5 deletions(-) create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 3032572afc..6ef8d72d3b 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -414,7 +414,11 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: if self.fork.is_after_fork("amsterdam"): assert block_env.state.change_tracker is not None num_transactions = ulen( - [tx for tx in self.txs.successfully_parsed if tx] + [ + tx_idx + for tx_idx in self.txs.successfully_parsed + if tx_idx is not None + ] ) # post-execution use n + 1 diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 4f51fd946b..9f1214fe4c 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -1417,3 +1417,511 @@ def test_bal_fully_unmutated_account( ) blockchain_test(pre=pre, blocks=[block], post={}) + + +def test_bal_empty_block_no_coinbase( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL correctly handles empty blocks without including coinbase. + + When a block has no transactions and no withdrawals, the coinbase/fee + recipient receives no fees and should not be included in the BAL. + """ + coinbase = pre.fund_eoa(amount=0) + + block = Block( + txs=[], + withdrawals=None, + fee_recipient=coinbase, + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + # Coinbase must NOT be included - receives no fees + coinbase: None, + } + ), + ) + + blockchain_test(pre=pre, blocks=[block], post={}) + + +def test_bal_coinbase_zero_tip( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """Ensure BAL includes coinbase even when priority fee is zero.""" + alice_initial_balance = 1_000_000 + alice = pre.fund_eoa(amount=alice_initial_balance) + bob = pre.fund_eoa(amount=0) + coinbase = pre.fund_eoa(amount=0) # fee recipient + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas = intrinsic_gas_calculator( + calldata=b"", + contract_creation=False, + access_list=[], + ) + tx_gas_limit = intrinsic_gas + 1000 + + # Calculate base fee + genesis_env = Environment(base_fee_per_gas=0x7) + base_fee_per_gas = fork.base_fee_per_gas_calculator()( + parent_base_fee_per_gas=int(genesis_env.base_fee_per_gas or 0), + parent_gas_used=0, + parent_gas_limit=genesis_env.gas_limit, + ) + + # Set gas_price equal to base_fee so tip = 0 + tx = Transaction( + sender=alice, + to=bob, + value=5, + gas_limit=tx_gas_limit, + gas_price=base_fee_per_gas, + ) + + alice_final_balance = ( + alice_initial_balance - 5 - (intrinsic_gas * base_fee_per_gas) + ) + + block = Block( + txs=[tx], + fee_recipient=coinbase, + header_verify=Header(base_fee_per_gas=base_fee_per_gas), + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=alice_final_balance + ) + ], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=5) + ] + ), + # Coinbase must be included even with zero tip + coinbase: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1, balance=alice_final_balance), + bob: Account(balance=5), + }, + genesis_environment=genesis_env, + ) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(10**18, id="with_value"), + pytest.param(0, id="no_value"), + ], +) +@pytest.mark.with_all_precompiles +def test_bal_precompile_funded( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + precompile: Address, + value: int, +) -> None: + """ + Ensure BAL records precompile value transfer. + + Alice sends value to precompile (pure value transfer). + If value > 0: BAL must include balance_changes. + If value = 0: BAL must have empty balance_changes. + """ + alice = pre.fund_eoa() + + addr_int = int.from_bytes(precompile, "big") + + # Map precompile addresses to their required minimal input sizes + # - Most precompiles accept zero-padded input of appropriate length + # - For 0x0a (POINT_EVALUATION), use a known valid input from mainnet + if addr_int == 0x0A: + # Valid point evaluation input from mainnet tx: + # https://etherscan.io/tx/0xcb3dc8f3b14f1cda0c16a619a112102a8ec70dce1b3f1b28272227cf8d5fbb0e + tx_data = ( + bytes.fromhex( + # versioned_hash (32) + "018156B94FE9735E573BAB36DAD05D60FEB720D424CCD20AAF719343C31E4246" + ) + + bytes.fromhex( + # z (32) + "019123BCB9D06356701F7BE08B4494625B87A7B02EDC566126FB81F6306E915F" + ) + + bytes.fromhex( + # y (32) + "6C2EB1E94C2532935B8465351BA1BD88EABE2B3FA1AADFF7D1CD816E8315BD38" + ) + + bytes.fromhex( + # kzg_commitment (48) + "A9546D41993E10DF2A7429B8490394EA9EE62807BAE6F326D1044A51581306F58D4B9DFD5931E044688855280FF3799E" + ) + + bytes.fromhex( + # kzg_proof (48) + "A2EA83D9391E0EE42E0C650ACC7A1F842A7D385189485DDB4FD54ADE3D9FD50D608167DCA6C776AAD4B8AD5C20691BFE" + ) + ) + else: + precompile_min_input = { + 0x01: 128, # ECRECOVER + 0x02: 0, # SHA256 (accepts empty) + 0x03: 0, # RIPEMD160 (accepts empty) + 0x04: 0, # IDENTITY (accepts empty) + 0x05: 96, # MODEXP + 0x06: 128, # BN256ADD + 0x07: 96, # BN256MUL + 0x08: 0, # BN256PAIRING (empty is valid) + 0x09: 213, # BLAKE2F + 0x0B: 256, # BLS12_G1_ADD + 0x0C: 160, # BLS12_G1_MSM + 0x0D: 512, # BLS12_G2_ADD + 0x0E: 288, # BLS12_G2_MSM + 0x0F: 384, # BLS12_PAIRING + 0x10: 64, # BLS12_MAP_FP_TO_G1 + 0x11: 128, # BLS12_MAP_FP2_TO_G2 + 0x100: 160, # P256VERIFY + } + + input_size = precompile_min_input.get(addr_int, 0) + tx_data = bytes([0x00] * input_size if input_size > 0 else []) + + tx = Transaction( + sender=alice, + to=precompile, + value=value, + gas_limit=5_000_000, + data=tx_data, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + precompile: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=value) + ] + if value > 0 + else [], + storage_reads=[], + storage_changes=[], + code_changes=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + }, + ) + + +@pytest.mark.parametrize_by_fork( + "precompile", + lambda fork: [ + pytest.param(addr, id=f"0x{int.from_bytes(addr, 'big'):02x}") + for addr in fork.precompiles(block_number=0, timestamp=0) + ], +) +def test_bal_precompile_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + precompile: Address, +) -> None: + """ + Ensure BAL records precompile when called via contract. + + Alice calls Oracle contract which calls precompile. + BAL must include precompile with no balance/storage/code changes. + """ + alice = pre.fund_eoa() + + # Oracle contract that calls the precompile + oracle = pre.deploy_contract( + code=Op.CALL(100_000, precompile, 0, 0, 0, 0, 0) + Op.STOP + ) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=200_000, + gas_price=0xA, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation.empty(), + precompile: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + }, + ) + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(0, id="zero_value"), + pytest.param(10**18, id="positive_value"), + ], +) +def test_bal_nonexistent_value_transfer( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + value: int, +) -> None: + """ + Ensure BAL captures non-existent account on value transfer. + + Alice sends value directly to non-existent Bob. + """ + alice = pre.fund_eoa() + bob = Address(0xB0B) + + tx = Transaction( + sender=alice, + to=bob, + value=value, + gas_limit=100_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=value) + ] + if value > 0 + else [], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=value) if value > 0 else Account.NONEXISTENT, + }, + ) + + +@pytest.mark.parametrize( + "account_access_opcode", + [ + pytest.param( + lambda target_addr: Op.BALANCE(target_addr), + id="balance", + ), + pytest.param( + lambda target_addr: Op.EXTCODESIZE(target_addr), + id="extcodesize", + ), + pytest.param( + lambda target_addr: Op.EXTCODECOPY(target_addr, 0, 0, 32), + id="extcodecopy", + ), + pytest.param( + lambda target_addr: Op.EXTCODEHASH(target_addr), + id="extcodehash", + ), + pytest.param( + lambda target_addr: Op.STATICCALL(0, target_addr, 0, 0, 0, 0), + id="staticcall", + ), + pytest.param( + lambda target_addr: Op.DELEGATECALL(0, target_addr, 0, 0, 0, 0), + id="delegatecall", + ), + ], +) +def test_bal_nonexistent_account_access_read_only( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + account_access_opcode: Callable[[Address], Op], +) -> None: + """ + Ensure BAL captures non-existent account access via read-only opcodes. + + Alice calls Oracle contract which uses read-only opcodes to access + non-existent Bob (BALANCE, EXTCODESIZE, EXTCODECOPY, EXTCODEHASH, + STATICCALL, DELEGATECALL). + """ + alice = pre.fund_eoa() + bob = Address(0xB0B) + oracle_balance = 2 * 10**18 + + oracle_code = account_access_opcode(bob) + oracle = pre.deploy_contract(code=oracle_code, balance=oracle_balance) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation.empty(), + bob: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account(balance=oracle_balance), + bob: Account.NONEXISTENT, + }, + ) + + +@pytest.mark.parametrize( + "opcode_type,value", + [ + pytest.param("call", 0, id="call_zero_value"), + pytest.param("call", 10**18, id="call_positive_value"), + pytest.param("callcode", 0, id="callcode_zero_value"), + pytest.param("callcode", 10**18, id="callcode_positive_value"), + ], +) +def test_bal_nonexistent_account_access_value_transfer( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + opcode_type: str, + value: int, +) -> None: + """ + Ensure BAL captures non-existent account access via CALL/CALLCODE + with value. + + Alice calls Oracle contract which uses CALL or CALLCODE to access + non-existent Bob with value transfer. + - CALL: Transfers value from Oracle to Bob + - CALLCODE: Self-transfer (net zero), Bob accessed for code + """ + alice = pre.fund_eoa() + bob = Address(0xB0B) + oracle_balance = 2 * 10**18 + + if opcode_type == "call": + oracle_code = Op.CALL(100_000, bob, value, 0, 0, 0, 0) + else: # callcode + oracle_code = Op.CALLCODE(100_000, bob, value, 0, 0, 0, 0) + + oracle = pre.deploy_contract(code=oracle_code, balance=oracle_balance) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + ) + + # Calculate expected balances + if opcode_type == "call" and value > 0: + # CALL: Oracle loses value, Bob gains value + oracle_final_balance = oracle_balance - value + bob_final_balance = value + bob_has_balance_change = True + oracle_has_balance_change = True + elif opcode_type == "callcode" and value > 0: + # CALLCODE: Self-transfer (net zero), Bob just accessed for code + oracle_final_balance = oracle_balance + bob_final_balance = 0 + bob_has_balance_change = False + oracle_has_balance_change = False + else: + # Zero value + oracle_final_balance = oracle_balance + bob_final_balance = 0 + bob_has_balance_change = False + oracle_has_balance_change = False + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=oracle_final_balance + ) + ] + if oracle_has_balance_change + else [], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=bob_final_balance + ) + ] + if bob_has_balance_change + else [], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account(balance=oracle_final_balance), + bob: Account(balance=bob_final_balance) + if bob_has_balance_change + else Account.NONEXISTENT, + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py new file mode 100644 index 0000000000..edb8295c17 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip4895.py @@ -0,0 +1,810 @@ +"""Tests for the effects of EIP-4895 withdrawals on EIP-7928.""" + +import pytest +from execution_testing import ( + EOA, + Account, + Address, + Alloc, + BalAccountExpectation, + BalBalanceChange, + BalCodeChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Environment, + Fork, + Header, + Initcode, + Op, + Transaction, + Withdrawal, + compute_create_address, +) + +from .spec import ref_spec_7928 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path +REFERENCE_SPEC_VERSION = ref_spec_7928.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + +GWEI = 10**9 + + +def test_bal_withdrawal_empty_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal balance changes in empty block. + + Charlie starts with 1 gwei balance (existing account). + Block with 0 transactions and 1 withdrawal of 10 gwei to Charlie. + Charlie ends with 11 gwei balance. + """ + charlie = pre.fund_eoa(amount=1 * GWEI) + + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=charlie, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=11 * GWEI) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + charlie: Account(balance=11 * GWEI), + }, + ) + + +def test_bal_withdrawal_and_transaction( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures both transaction and withdrawal balance changes. + + Alice starts with 1 ETH, Bob starts with 0, Charlie starts with 0. + Alice sends 5 wei to Bob. + Charlie receives 10 gwei withdrawal. + Bob ends with 5 wei, Charlie ends with 10 gwei. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + charlie = pre.fund_eoa(amount=0) + + tx = Transaction( + sender=alice, + to=bob, + value=5, + max_fee_per_gas=50, + max_priority_fee_per_gas=5, + ) + + block = Block( + txs=[tx], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=charlie, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=5) + ], + ), + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=2, post_balance=10 * GWEI) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=5), + charlie: Account(balance=10 * GWEI), + }, + ) + + +def test_bal_withdrawal_to_nonexistent_account( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal to non-existent account. + + Charlie is a non-existent address (not in pre-state). + Block with 0 transactions and 1 withdrawal of 10 gwei to Charlie. + Charlie ends with 10 gwei balance. + """ + charlie = Address(0xCC) + + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=charlie, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=10 * GWEI) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + charlie: Account(balance=10 * GWEI), + }, + ) + + +def test_bal_withdrawal_no_evm_execution( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal without triggering EVM execution. + + Oracle contract starts with 0 balance and storage slot 0x01 = 0x42. + Oracle's code writes 0xFF to slot 0x01 when called. + Block with 0 transactions and 1 withdrawal of 10 gwei to Oracle. + Storage slot 0x01 remains 0x42 (EVM never executes). + """ + oracle = pre.deploy_contract( + code=Op.SSTORE(0x01, 0xFF), + storage={0x01: 0x42}, + ) + + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=oracle, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + oracle: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=10 * GWEI) + ], + storage_reads=[], + storage_changes=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + oracle: Account( + balance=10 * GWEI, + storage={0x01: 0x42}, + ), + }, + ) + + +def test_bal_withdrawal_and_state_access_same_account( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures both state access and withdrawal to same address. + + Oracle contract starts with 0 balance and storage slot 0x01 = 0x42. + Alice calls Oracle (reads slot 0x01, writes 0x99 to slot 0x02). + Oracle receives withdrawal of 10 gwei. + Both state access and withdrawal are captured in BAL. + """ + alice = pre.fund_eoa() + oracle = pre.deploy_contract( + code=Op.SLOAD(0x01) + Op.SSTORE(0x02, 0x99), + storage={0x01: 0x42}, + ) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + gas_price=0xA, + ) + + block = Block( + txs=[tx], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=oracle, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + storage_reads=[0x01], + storage_changes=[ + BalStorageSlot( + slot=0x02, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0x99) + ], + ) + ], + balance_changes=[ + BalBalanceChange(tx_index=2, post_balance=10 * GWEI) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account( + balance=10 * GWEI, + storage={0x01: 0x42, 0x02: 0x99}, + ), + }, + ) + + +def test_bal_withdrawal_and_value_transfer_same_address( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures both value transfer and withdrawal to same address. + + Alice starts with 1 ETH, Bob starts with 0. + Alice sends 5 gwei to Bob. + Bob receives withdrawal of 10 gwei. + Bob ends with 15 gwei (5 from tx + 10 from withdrawal). + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + + tx = Transaction( + sender=alice, + to=bob, + value=5 * GWEI, + gas_price=0xA, + ) + + block = Block( + txs=[tx], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=bob, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=5 * GWEI), + BalBalanceChange(tx_index=2, post_balance=15 * GWEI), + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=15 * GWEI), + }, + ) + + +def test_bal_multiple_withdrawals_same_address( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL accumulates multiple withdrawals to same address. + + Charlie starts with 0 balance. + Block empty block with 3 withdrawals to Charlie: 5 gwei, 10 gwei, 15 gwei. + Charlie ends with 30 gwei balance (cumulative). + """ + charlie = pre.fund_eoa(amount=0) + + block = Block( + txs=[], + withdrawals=[ + Withdrawal(index=i, validator_index=i, address=charlie, amount=amt) + for i, amt in enumerate([5, 10, 15]) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=30 * GWEI) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + charlie: Account(balance=30 * GWEI), + }, + ) + + +def test_bal_withdrawal_and_selfdestruct( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal to self-destructed contract address. + + Oracle contract starts with 100 gwei balance. + Alice triggers Oracle to self-destruct, sending balance to Bob. + Oracle receives withdrawal of 50 gwei after self-destructing. + Oracle ends with 50 gwei (funded by withdrawal). + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + oracle = pre.deploy_contract( + balance=100 * GWEI, + code=Op.SELFDESTRUCT(bob), + ) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + gas_price=0xA, + ) + + block = Block( + txs=[tx], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=oracle, + amount=50, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=100 * GWEI) + ], + ), + oracle: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=0), + BalBalanceChange(tx_index=2, post_balance=50 * GWEI), + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=100 * GWEI), + oracle: Account(balance=50 * GWEI), + }, + ) + + +def test_bal_withdrawal_and_new_contract( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal to newly created contract. + + Alice deploys Oracle contract with 5 gwei initial balance. + Oracle receives withdrawal of 10 gwei in same block. + Oracle ends with 15 gwei (5 from deployment + 10 from withdrawal). + """ + alice = pre.fund_eoa() + + code = Op.STOP + initcode = Initcode(deploy_code=code) + oracle = compute_create_address(address=alice) + + tx = Transaction( + sender=alice, + to=None, + data=initcode, + value=5 * GWEI, + gas_limit=1_000_000, + gas_price=0xA, + ) + + block = Block( + txs=[tx], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=oracle, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + code_changes=[BalCodeChange(tx_index=1, new_code=code)], + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=5 * GWEI), + BalBalanceChange(tx_index=2, post_balance=15 * GWEI), + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account(balance=15 * GWEI, code=code), + }, + ) + + +@pytest.mark.parametrize( + "initial_balance", + [ + pytest.param(5 * GWEI, id="existing_account"), + pytest.param(0, id="nonexistent_account"), + ], +) +def test_bal_zero_withdrawal( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + initial_balance: int, +) -> None: + """ + Ensure BAL handles zero-amount withdrawal correctly. + + Charlie either exists with initial balance or is non-existent. + Block with 0 transactions and 1 zero-amount withdrawal to Charlie. + Charlie appears in BAL but with empty changes, balance unchanged. + """ + if initial_balance > 0: + charlie = pre.fund_eoa(amount=initial_balance) + else: + charlie = EOA(0xCC) + + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=charlie, + amount=0, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + charlie: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + charlie: Account(balance=initial_balance) + if initial_balance > 0 + else Account.NONEXISTENT, + }, + ) + + +@pytest.mark.parametrize_by_fork( + "precompile", + lambda fork: [ + pytest.param(addr, id=f"0x{int.from_bytes(addr, 'big'):02x}") + for addr in fork.precompiles(block_number=0, timestamp=0) + ], +) +def test_bal_withdrawal_to_precompiles( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + precompile: Address, +) -> None: + """ + Ensure BAL captures withdrawal to precompile addresses. + + Block with 0 transactions and 1 withdrawal of 10 gwei to precompile. + Precompile ends with 10 gwei balance. + """ + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=precompile, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + precompile: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=10 * GWEI) + ], + storage_reads=[], + storage_changes=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + precompile: Account(balance=10 * GWEI), + }, + ) + + +def test_bal_withdrawal_largest_amount( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal with largest amount. + + Block with 0 transactions and 1 withdrawal of maximum + uint64 value (2^64-1)Gwei to Charlie. + Charlie ends with (2^64-1) Gwei. + """ + charlie = pre.fund_eoa(amount=0) + max_amount = 2**64 - 1 + + block = Block( + txs=[], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=charlie, + amount=max_amount, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=max_amount * GWEI + ) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + charlie: Account(balance=max_amount * GWEI), + }, + ) + + +def test_bal_withdrawal_to_coinbase( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """ + Ensure BAL captures withdrawal to coinbase address. + + Block with 1 transaction and 1 withdrawal to coinbase/fee recipient. + Coinbase receives both transaction fees and withdrawal. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + coinbase = pre.fund_eoa(amount=0) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas = intrinsic_gas_calculator() + tx_gas_limit = intrinsic_gas + 1000 + gas_price = 0xA + + tx = Transaction( + sender=alice, + to=bob, + value=5, + gas_limit=tx_gas_limit, + gas_price=gas_price, + ) + + # Calculate tip to coinbase + genesis_env = Environment(base_fee_per_gas=0x7) + base_fee_per_gas = fork.base_fee_per_gas_calculator()( + parent_base_fee_per_gas=int(genesis_env.base_fee_per_gas or 0), + parent_gas_used=0, + parent_gas_limit=genesis_env.gas_limit, + ) + tip_to_coinbase = (gas_price - base_fee_per_gas) * intrinsic_gas + coinbase_final_balance = tip_to_coinbase + (10 * GWEI) + + block = Block( + txs=[tx], + fee_recipient=coinbase, + header_verify=Header(base_fee_per_gas=base_fee_per_gas), + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=coinbase, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=5) + ], + ), + coinbase: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=tip_to_coinbase + ), + BalBalanceChange( + tx_index=2, post_balance=coinbase_final_balance + ), + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + bob: Account(balance=5), + coinbase: Account(balance=coinbase_final_balance), + }, + genesis_environment=genesis_env, + ) + + +def test_bal_withdrawal_to_coinbase_empty_block( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures withdrawal to coinbase when there are no transactions. + + Empty block with 1 withdrawal of 10 gwei to coinbase/fee recipient. + Coinbase receives only withdrawal (no transaction fees). + """ + coinbase = pre.fund_eoa(amount=0) + + block = Block( + txs=[], + fee_recipient=coinbase, + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=coinbase, + amount=10, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + coinbase: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=10 * GWEI) + ], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + coinbase: Account(balance=10 * GWEI), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 5e5ebaefe3..17799d3655 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -15,12 +15,14 @@ """ from enum import Enum +from typing import Callable import pytest from execution_testing import ( Account, Alloc, BalAccountExpectation, + BalNonceChange, BalStorageChange, BalStorageSlot, Block, @@ -606,3 +608,149 @@ def test_bal_extcodecopy_and_oog( target_contract: Account(), }, ) + + +def test_bal_storage_write_read_same_frame( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures write precedence over read in same call frame. + + Oracle writes to slot 0x01, then reads from slot 0x01 in same call. + The write shadows the read - only the write appears in BAL. + """ + alice = pre.fund_eoa() + + oracle_code = ( + Op.SSTORE(0x01, 0x42) # Write 0x42 to slot 0x01 + + Op.SLOAD(0x01) # Read from slot 0x01 + + Op.STOP + ) + oracle = pre.deploy_contract(code=oracle_code, storage={0x01: 0x99}) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0x42) + ], + ) + ], + storage_reads=[], # Empty! Write shadows the read + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account(storage={0x01: 0x42}), + }, + ) + + +@pytest.mark.parametrize( + "call_opcode", + [ + pytest.param( + lambda target: Op.CALL(100_000, target, 0, 0, 0, 0, 0), id="call" + ), + pytest.param( + lambda target: Op.DELEGATECALL(100_000, target, 0, 0, 0, 0), + id="delegatecall", + ), + pytest.param( + lambda target: Op.CALLCODE(100_000, target, 0, 0, 0, 0, 0), + id="callcode", + ), + ], +) +def test_bal_storage_write_read_cross_frame( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + call_opcode: Callable[[Bytecode], Bytecode], +) -> None: + """ + Ensure BAL captures write precedence over read across call frames. + + Frame 1: Read slot 0x01 (0x99), write 0x42, then call itself. + Frame 2: Read slot 0x01 (0x42), see it's 0x42 and return. + Both reads are shadowed by the write - only write appears in BAL. + """ + alice = pre.fund_eoa() + + # Oracle code: + # 1. Read slot 0x01 (initial: 0x99, recursive: 0x42) + # 2. If value == 0x42, return (exit recursion) + # 3. Write 0x42 to slot 0x01 + # 4. Call itself recursively + oracle_code = ( + Op.SLOAD(0x01) # Load value from slot 0x01 + + Op.PUSH1(0x42) # Push 0x42 for comparison + + Op.EQ # Check if loaded value == 0x42 + + Op.PUSH1(0x1D) # Jump destination (after SSTORE + CALL) + + Op.JUMPI # If equal, jump to end (exit recursion) + + Op.PUSH1(0x42) # Value to write + + Op.PUSH1(0x01) # Slot 0x01 + + Op.SSTORE # Write 0x42 to slot 0x01 + + call_opcode(Op.ADDRESS) # Call itself + + Op.JUMPDEST # Jump destination for exit + + Op.STOP + ) + + oracle = pre.deploy_contract(code=oracle_code, storage={0x01: 0x99}) + + tx = Transaction( + sender=alice, + to=oracle, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + oracle: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0x42) + ], + ) + ], + storage_reads=[], # Empty! Write shadows both reads + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + oracle: Account(storage={0x01: 0x42}), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 945330b628..351395479d 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -29,8 +29,8 @@ | `test_bal_pure_contract_call` | Ensure BAL captures contract access for pure computation calls | Alice calls `PureContract` that performs pure arithmetic (ADD operation) without storage or balance changes | BAL MUST include Alice and `PureContract` in `account_changes`, and `nonce_changes` for Alice. | ✅ Completed | | `test_bal_create2_to_A_read_then_selfdestruct` | BAL records balance change for A and storage access (no persistent change) | Tx0: Alice sends ETH to address **A**. Tx1: Deployer `CREATE2` a contract **at A**; contract does `SLOAD(B)` and immediately `SELFDESTRUCT(beneficiary=X)` in the same tx. | BAL **MUST** include **A** with `balance_changes` (funding in Tx0 and transfer on selfdestruct in Tx1). BAL **MUST** include storage key **B** as an accessed `StorageKey`, and **MUST NOT** include **B** under `storage_changes` (no persistence due to same-tx create+destruct). | 🟡 Planned | | `test_bal_create2_to_A_write_then_selfdestruct` | BAL records balance change for A and storage access even if a write occurred (no persistent change) | Tx0: Alice sends ETH to **A**. Tx1: Deployer `CREATE2` contract **at A**; contract does `SSTORE(B, v)` (optionally `SLOAD(B)`), then `SELFDESTRUCT(beneficiary=Y)` in the same tx. | BAL **MUST** include **A** with `balance_changes` (Tx0 fund; Tx1 outflow to `Y`). BAL **MUST** include **B** as `StorageKey` accessed, and **MUST NOT** include **B** under `storage_changes` (ephemeral write discarded because the contract was created and destroyed in the same tx). | 🟡 Planned | -| `test_bal_precompile_funded_then_called` | BAL records precompile with balance change (fund) and access (call) | **Tx0**: Alice sends `1 ETH` to `ecrecover` (0x01). **Tx1**: Alice (or Bob) calls `ecrecover` with valid input and `0 ETH`. | BAL **MUST** include address `0x01` with `balance_changes` (from Tx0). No `storage_changes` or `code_changes`. | 🟡 Planned | -| `test_bal_precompile_call_only` | BAL records precompile when called with no balance change | Alice calls `ecrecover` (0x01) with a valid input, sending **0 ETH**. | BAL **MUST** include address `0x01` in access list, with **no** `balance_changes`, `storage_changes`, or `code_changes`. | 🟡 Planned | +| `test_bal_precompile_funded` | BAL records precompile value transfer with or without balance change | Alice sends value to precompile (all precompiles) via direct transaction. Parameterized: (1) with value (1 ETH), (2) without value (0 ETH). | For with_value: BAL **MUST** include precompile with `balance_changes`. For no_value: BAL **MUST** include precompile with empty `balance_changes`. No `storage_changes` or `code_changes` in either case. | ✅ Completed | +| `test_bal_precompile_call` | BAL records precompile when called via contract | Alice calls Oracle contract which calls precompile (all precompiles) via CALL opcode with 0 ETH | BAL **MUST** include Alice with `nonce_changes`, Oracle with empty changes, and precompile with empty changes. No `balance_changes`, `storage_changes`, or `code_changes` for precompile. | ✅ Completed | | `test_bal_7702_delegated_create` | BAL tracks EIP-7702 delegation indicator write and contract creation | Alice sends a type-4 (7702) tx authorizing herself to delegate to `Deployer` code which executes `CREATE` | BAL MUST include for **Alice**: `code_changes` (delegation indicator), `nonce_changes` (increment from 7702 processing), and `balance_changes` (post-gas). For **Child**: `code_changes` (runtime bytecode) and `nonce_changes = 1`. | 🟡 Planned | | `test_bal_7702_delegation_create` | Ensure BAL captures creation of EOA delegation | Alice authorizes delegation to contract `Oracle`. Transaction sends 10 wei to Bob. Two variants: (1) Self-funded: Alice sends 7702 tx herself. (2) Sponsored: `Relayer` sends 7702 tx on Alice's behalf. | BAL **MUST** include Alice: `code_changes` (delegation designation `0xef0100\|\|address(Oracle)`),`nonce_changes` (increment). Bob: `balance_changes` (receives 10 wei). For sponsored variant, BAL **MUST** also include `Relayer`:`nonce_changes`.`Oracle` **MUST NOT** be present in BAL - the account is never accessed. | ✅ Completed | | `test_bal_7702_delegation_update` | Ensure BAL captures update of existing EOA delegation | Alice first delegates to `Oracle1`, then in second tx updates delegation to `Oracle2`. Each transaction sends 10 wei to Bob. Two variants: (1) Self-funded: Alice sends both 7702 txs herself. (2) Sponsored: `Relayer` sends both 7702 txs on Alice's behalf. | BAL **MUST** include Alice: first tx has `code_changes` (delegation designation `0xef0100\|\|address(Oracle1)`),`nonce_changes`. Second tx has`code_changes` (delegation designation `0xef0100\|\|address(Oracle2)`),`nonce_changes`. Bob:`balance_changes` (receives 10 wei on each tx). For sponsored variant, BAL **MUST** also include `Relayer`:`nonce_changes` for both transactions. `Oracle1` and `Oracle2` **MUST NOT** be present in BAL - accounts are never accessed. | ✅ Completed | @@ -58,5 +58,24 @@ | `test_bal_invalid_complex_corruption` | Verify clients reject blocks with multiple BAL corruptions | Alice calls contract with storage writes; BAL has multiple issues: wrong account, missing nonce, wrong storage value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** detect any corruption regardless of other issues. | ✅ Completed | | `test_bal_invalid_missing_account` | Verify clients reject blocks with missing required account entries in BAL | Alice sends transaction to Bob; BAL modifier removes Bob's account entry (recipient should be included) | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate all accessed accounts are present. | ✅ Completed | | `test_bal_invalid_balance_value` | Verify clients reject blocks with incorrect balance values in BAL | Alice sends value to Bob; BAL modifier changes balance to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate balance change values match actual state transitions. | ✅ Completed | -| `test_bal_empty_block_no_coinbase` | Verify BAL correctly handles empty blocks without including coinbase | Block with 0 transactions, no withdrawals. System contracts may perform operations (EIP-2935 parent hash, EIP-4788 beacon root if active). | BAL **MUST NOT** include the coinbase/fee recipient (receives no fees). BAL **MAY** include system contract addresses (EIP-2935 `HISTORY_STORAGE_ADDRESS`, EIP-4788 `BEACON_ROOTS_ADDRESS`) with `storage_changes` at `tx_index=0` (pre-execution system operations). Maximum 4 system contract addresses if all active. | 🟡 Planned | -| `test_bal_empty_block_withdrawal_to_coinbase` | Verify BAL includes coinbase when it receives EIP-4895 withdrawal even in empty block | Block with 0 transactions but contains EIP-4895 withdrawal(s) with coinbase as recipient. System contracts may perform operations. | BAL **MUST** include coinbase with `balance_changes` at `tx_index=1` (post-execution: len(txs)+1 = 0+1). BAL **MAY** include system contract addresses with `storage_changes` at `tx_index=0` (pre-execution system operations). This confirms that coinbase inclusion depends on actual state changes, not transaction presence. | 🟡 Planned | +| `test_bal_empty_block_no_coinbase` | Ensure BAL correctly handles empty blocks without including coinbase | Block with 0 transactions, no withdrawals. System contracts may perform operations (EIP-2935 parent hash, EIP-4788 beacon root if active). | BAL **MUST NOT** include the coinbase/fee recipient (receives no fees). BAL **MAY** include system contract addresses (EIP-2935 `HISTORY_STORAGE_ADDRESS`, EIP-4788 `BEACON_ROOTS_ADDRESS`) with `storage_changes` at `tx_index=0` (pre-execution system operations). | ✅ Completed | +| `test_bal_coinbase_zero_tip` | Ensure BAL includes coinbase even when priority fee is zero | Block with 1 transaction: Alice sends 5 wei to Bob with priority fee = 0 (base fee burned post-EIP-1559) | BAL **MUST** include Alice with `balance_changes` (gas cost) and `nonce_changes`. BAL **MUST** include Bob with `balance_changes`. BAL **MUST** include coinbase with empty changes. | ✅ Completed | +| `test_bal_withdrawal_empty_block` | Ensure BAL captures withdrawal balance changes in empty block | Charlie starts with 1 gwei. Block with 0 transactions and 1 withdrawal of 10 gwei to Charlie | BAL **MUST** include Charlie with `balance_changes` at `block_access_index = 1`. Charlie's `balance_changes` **MUST** show final balance of 11 gwei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | ✅ Completed | +| `test_bal_withdrawal_and_transaction` | Ensure BAL captures both transaction and withdrawal balance changes | Block with 1 transaction: Alice sends 5 wei to Bob. 1 withdrawal of 10 gwei to Charlie | BAL **MUST** include Alice with `nonce_changes` and `balance_changes` at `block_access_index = 1`. BAL **MUST** include Bob with `balance_changes` at `block_access_index = 1`. BAL **MUST** include Charlie with `balance_changes` at `block_access_index = 2` showing final balance after receiving 10 gwei. All other fields for Charlie **MUST** be empty. | ✅ Completed | +| `test_bal_withdrawal_to_nonexistent_account` | Ensure BAL captures withdrawal to non-existent account | Block with 1 withdrawal of 10 gwei to non-existent account Charlie | BAL **MUST** include Charlie with `balance_changes` at `block_access_index = 1` showing final balance of 10 gwei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | ✅ Completed | +| `test_bal_withdrawal_no_evm_execution` | Ensure BAL captures withdrawal without triggering EVM execution | Contract `Oracle` with storage slot 0x01 = 0x42. `Oracle` code writes to slot 0x01 when called. Block with 1 withdrawal of 10 gwei to `Oracle` | BAL **MUST** include `Oracle` with `balance_changes` at `block_access_index = 1` showing final balance after receiving 10 gwei. Storage slot 0x01 **MUST** remain 0x42 and all other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | ✅ Completed | +| `test_bal_withdrawal_and_state_access_same_account` | Ensure BAL captures both state access and withdrawal to same address | Contract `Oracle` with storage slot 0x01 = 0x42. Block with 1 transaction: Alice calls `Oracle` (reads from slot 0x01, writes to slot 0x02). 1 withdrawal of 10 gwei to `Oracle` | BAL **MUST** include `Oracle` with `storage_reads` for slot 0x01 and `storage_changes` for slot 0x02 at `block_access_index = 1`. `Oracle` **MUST** also have `balance_changes` at `block_access_index = 2` showing final balance after receiving 10 gwei. Both state access and withdrawal **MUST** be captured. | ✅ Completed | +| `test_bal_withdrawal_and_value_transfer_same_address` | Ensure BAL captures both transaction value transfer and withdrawal to same address | Block with 1 transaction: Alice sends 5 gwei to Bob. 1 withdrawal of 10 gwei to Bob | BAL **MUST** include Alice with `nonce_changes` and `balance_changes` at `block_access_index = 1`. BAL **MUST** include Bob with `balance_changes` at `block_access_index = 1` showing balance after receiving 5 gwei. Bob **MUST** also have `balance_changes` at `block_access_index = 2` showing balance after receiving 10 gwei withdrawal. Bob's final post-state balance **MUST** be 15 gwei (cumulative). | ✅ Completed | +| `test_bal_multiple_withdrawals_same_address` | Ensure BAL accumulates multiple withdrawals to same address | Block with 3 withdrawals to Charlie: 5 gwei, 10 gwei, 15 gwei | BAL **MUST** include Charlie with `balance_changes` at `block_access_index = 1` showing final balance of 30 gwei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | ✅ Completed | +| `test_bal_withdrawal_and_selfdestruct` | Ensure BAL captures withdrawal to self-destructed contract address | Contract `Oracle` with 100 gwei balance. Block with 1 transaction: `Oracle` self-destructs sending balance to Bob. 1 withdrawal of 50 gwei to `Oracle`'s address | BAL **MUST** include `Oracle` with `balance_changes` showing 0 balance at `block_access_index = 1` (after self-destruct). BAL **MUST** include Bob with `balance_changes` showing 100 gwei received from self-destruct at `block_access_index = 1`. `Oracle` **MUST** also have `balance_changes` at `block_access_index = 2` showing 50 gwei after withdrawal. Both self-destruct and withdrawal **MUST** be captured. | ✅ Completed | +| `test_bal_withdrawal_and_new_contract` | Ensure BAL captures withdrawal to newly created contract | Block with 1 transaction: Alice deploys contract `Oracle` with 5 gwei initial balance. 1 withdrawal of 10 gwei to `Oracle` | BAL **MUST** include `Oracle` with `code_changes` and `balance_changes` showing 5 gwei at `block_access_index = 1`. `Oracle` **MUST** also have `balance_changes` at `block_access_index = 2` showing balance after receiving 10 gwei withdrawal. `Oracle`'s final post-state balance **MUST** be 15 gwei (cumulative). | ✅ Completed | +| `test_bal_zero_withdrawal` | Ensure BAL handles zero-amount withdrawal correctly | Block with 0 transactions and 1 zero-amount withdrawal (0 gwei) to Charlie. Two variations: Charlie has existing balance (5 gwei) or Charlie is non-existent. | BAL **MUST** include Charlie at `block_access_index = 1` with empty changes. Balance remains unchanged. | ✅ Completed | +| `test_bal_withdrawal_to_precompiles` | Ensure BAL captures withdrawal to precompile addresses | Block with 1 withdrawal of 10 gwei to precompile address (all precompiles) | BAL **MUST** include precompile address with `balance_changes` at `block_access_index = 1` showing final balance of 10 gwei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | ✅ Completed | +| `test_bal_withdrawal_largest_amount` | Ensure BAL captures withdrawal with largest amount | Block with 1 withdrawal of maximum uint64 value (2^64-1 gwei) to Charlie | BAL **MUST** include Charlie with `balance_changes` at `block_access_index = 1` showing final balance of (2^64-1) * 10^9 wei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | ✅ Completed | +| `test_bal_withdrawal_to_coinbase` | Ensure BAL captures withdrawal to coinbase address | Block with 1 transaction: Alice sends 5 wei to Bob. 1 withdrawal of 10 gwei to coinbase/fee recipient | BAL **MUST** include coinbase with `balance_changes` at `block_access_index = 1` showing balance after transaction fees. Coinbase **MUST** also have `balance_changes` at `block_access_index = 2` showing balance after receiving 10 gwei withdrawal. Coinbase's final post-state balance **MUST** include both transaction fees and withdrawal. | ✅ Completed | +| `test_bal_withdrawal_to_coinbase_empty_block` | Ensure BAL captures withdrawal to coinbase even when there are no transactions (no fees) | Block with 0 transactions and 1 withdrawal of 10 gwei to coinbase/fee recipient | BAL **MUST** include coinbase with `balance_changes` at `block_access_index = 1` showing final balance of 10 gwei. All other fields (storage_reads, storage_changes, nonce_changes, code_changes) **MUST** be empty. | ✅ Completed | +| `test_bal_nonexistent_value_transfer` | Ensure BAL captures non-existent account on value transfer | Alice sends value (0 wei or 1 ETH) to non-existent account Bob (address never funded or accessed before) via direct transfer | For zero value: BAL **MUST** include Alice with `nonce_changes` and Bob (non-existent) with empty changes. For positive value: BAL **MUST** include Bob with `balance_changes` showing received amount. | ✅ Completed | +| `test_bal_nonexistent_account_access_read_only` | Ensure BAL captures non-existent account accessed via read-only account-reading opcodes | Alice calls `Oracle` contract which uses read-only account access opcodes (`BALANCE`, `EXTCODESIZE`, `EXTCODECOPY`, `EXTCODEHASH`, `STATICCALL`, `DELEGATECALL`) on non-existent account Bob. | BAL **MUST** include Alice with `nonce_changes`, `Oracle` with empty changes, and Bob with empty changes (account accessed but no state modifications). | ✅ Completed | +| `test_bal_nonexistent_account_access_value_transfer` | Ensure BAL captures non-existent account accessed via CALL/CALLCODE with value transfers | Alice calls `Oracle` contract which uses `CALL` or `CALLCODE` on non-existent account Bob. Tests both zero and positive value transfers. | BAL **MUST** include Alice with `nonce_changes`. For CALL with positive value: `Oracle` with `balance_changes` (loses value), Bob with `balance_changes` (receives value). For CALLCODE with value or zero value transfers: `Oracle` and Bob with empty changes (CALLCODE self-transfer = net zero). | ✅ Completed | +| `test_bal_storage_write_read_same_frame` | Ensure BAL captures write precedence over read in same call frame (writes shadow reads) | Alice calls `Oracle` which writes (`SSTORE`) value `0x42` to slot `0x01`, then reads (`SLOAD`) from slot `0x01` in the same call frame | BAL **MUST** include `Oracle` with slot `0x01` in `storage_changes` showing final value `0x42`. Slot `0x01` **MUST NOT** appear in `storage_reads` (write shadows the subsequent read in same frame). | ✅ Completed | +| `test_bal_storage_write_read_cross_frame` | Ensure BAL captures write precedence over read across call frames (writes shadow reads cross-frame) | Alice calls `Oracle`. First call reads slot `0x01` (sees initial value), writes `0x42` to slot `0x01`, then calls itself (via `CALL`, `DELEGATECALL`, or `CALLCODE`). Second call reads slot `0x01` (sees `0x42`) and exits. | BAL **MUST** include `Oracle` with slot `0x01` in `storage_changes` showing final value `0x42`. Slot `0x01` **MUST NOT** appear in `storage_reads` (write shadows both the read before it in same frame and the read in the recursive call). | ✅ Completed | From 6da8a02c88becb4655263a11bcc41f88ed6a19de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= <51536394+nerolation@users.noreply.github.com> Date: Fri, 31 Oct 2025 18:22:24 +0100 Subject: [PATCH 07/51] fix(specs): Ensure tracking before first access (#1722) --- .../forks/amsterdam/vm/instructions/system.py | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index c9feaabbc7..1de3a140a1 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -110,6 +110,9 @@ def generic_create( evm.accessed_addresses.add(contract_address) + # Track address access for BAL + track_address_access(state.change_tracker, contract_address) + if account_has_code_or_nonce( state, contract_address ) or account_has_storage(state, contract_address): @@ -145,8 +148,6 @@ def generic_create( parent_evm=evm, ) - track_address_access(state.change_tracker, contract_address) - child_evm = process_create_message(child_message) if child_evm.error: @@ -341,8 +342,6 @@ def generic_call( parent_evm=evm, ) - track_address_access(evm.message.block_env.state.change_tracker, to) - child_evm = process_message(child_message) if child_evm.error: @@ -396,6 +395,9 @@ def call(evm: Evm) -> None: evm.accessed_addresses.add(to) access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + # Track address access for BAL + track_address_access(evm.message.block_env.state.change_tracker, to) + code_address = to ( disable_precompiles, @@ -485,6 +487,11 @@ def callcode(evm: Evm) -> None: evm.accessed_addresses.add(code_address) access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + # Track address access for BAL + track_address_access( + evm.message.block_env.state.change_tracker, code_address + ) + ( disable_precompiles, code_address, @@ -503,10 +510,6 @@ def callcode(evm: Evm) -> None: ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) - track_address_access( - evm.message.block_env.state.change_tracker, code_address - ) - # OPERATION evm.memory += b"\x00" * extend_memory.expand_by sender_balance = get_account( @@ -560,6 +563,11 @@ def selfdestruct(evm: Evm) -> None: evm.accessed_addresses.add(beneficiary) gas_cost += GAS_COLD_ACCOUNT_ACCESS + # Track address access for BAL + track_address_access( + evm.message.block_env.state.change_tracker, beneficiary + ) + if ( not is_account_alive(evm.message.block_env.state, beneficiary) and get_account( @@ -635,6 +643,11 @@ def delegatecall(evm: Evm) -> None: evm.accessed_addresses.add(code_address) access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + # Track address access for BAL + track_address_access( + evm.message.block_env.state.change_tracker, code_address + ) + ( disable_precompiles, code_address, @@ -648,10 +661,6 @@ def delegatecall(evm: Evm) -> None: ) charge_gas(evm, message_call_gas.cost + extend_memory.cost) - track_address_access( - evm.message.block_env.state.change_tracker, code_address - ) - # OPERATION evm.memory += b"\x00" * extend_memory.expand_by generic_call( @@ -708,6 +717,9 @@ def staticcall(evm: Evm) -> None: evm.accessed_addresses.add(to) access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + # Track address access for BAL + track_address_access(evm.message.block_env.state.change_tracker, to) + code_address = to ( disable_precompiles, From 743b06df1c70db24303c134738e7016871399cbf Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 3 Nov 2025 10:08:58 -0700 Subject: [PATCH 08/51] chore(tests): fill all tests for bal releases --- .github/configs/feature.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/configs/feature.yaml b/.github/configs/feature.yaml index d51e7e0144..227779994f 100644 --- a/.github/configs/feature.yaml +++ b/.github/configs/feature.yaml @@ -23,5 +23,6 @@ benchmark_fast: bal: evm-type: develop - fill-params: --fork=Amsterdam ./tests/amsterdam/eip7928_block_level_access_lists + # TODO: Turn on block rlp limit tests after making filling them more flexible. + fill-params: --fork=Amsterdam -k "not eip7934" feature_only: true From 3b17eb1a048d68552277dea78060272e742b90fb Mon Sep 17 00:00:00 2001 From: felipe Date: Tue, 4 Nov 2025 04:44:36 -0700 Subject: [PATCH 09/51] fix(spec-specs): duplicate storage writes in state tracker (#1743) - Perform a similar check to balance changes and other tracker methods and keep only the last write. --- docs/CHANGELOG.md | 1 + .../amsterdam/block_access_lists/builder.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cdc6168306..6aeefed9f4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -48,6 +48,7 @@ Test fixtures for use by clients are available for each release on the [Github r ### 📋 Misc - 🐞 WELDed the EEST tox environments relevant to producing documentation into EELS, and added a tool to cleanly add codespell whitelist entries. ([#1695](https://github.com/ethereum/execution-specs/pull/1659)). +- 🐞 Fix duplicate storage write issues for block access lists EIP-7928 implementation ([#1743](https://github.com/ethereum/execution-specs/pull/1743)). ### 🧪 Test Cases diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index a9d6ee9930..f27e26c377 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -126,8 +126,8 @@ def add_storage_write( Add a storage write operation to the block access list. Records a storage slot modification for a given address at a specific - transaction index. Multiple writes to the same slot are tracked - separately, maintaining the order and transaction index of each change. + transaction index. If multiple writes occur to the same slot within the + same transaction (same block_access_index), only the final value is kept. Parameters ---------- @@ -149,6 +149,18 @@ def add_storage_write( if slot not in builder.accounts[address].storage_changes: builder.accounts[address].storage_changes[slot] = [] + # Check if there's already an entry with the same block_access_index + # If so, update it with the new value, keeping only the final write + changes = builder.accounts[address].storage_changes[slot] + for i, existing_change in enumerate(changes): + if existing_change.block_access_index == block_access_index: + # Update the existing entry with the new value + changes[i] = StorageChange( + block_access_index=block_access_index, new_value=new_value + ) + return + + # No existing entry found, append new change change = StorageChange( block_access_index=block_access_index, new_value=new_value ) From c7f83a3355077cfb5e3e10f2515e89630838c4a8 Mon Sep 17 00:00:00 2001 From: felipe Date: Thu, 13 Nov 2025 09:05:36 -0700 Subject: [PATCH 10/51] fix(test-specs): validate t8n BAL independent of expectation existence (#1742) - Validate static checks on the t8n BAL if it exists - IF the expectation also exists, validate against the expectation Keep these checks separate as this helps validation now that we fill for all tests, regardless if they have an expectation or not. --- .../src/execution_testing/specs/blockchain.py | 6 + .../block_access_list/expectations.py | 114 +----- .../test_types/block_access_list/t8n.py | 106 ++++++ ... => test_block_access_list_expectation.py} | 225 ----------- .../tests/test_block_access_list_t8n.py | 351 ++++++++++++++++++ 5 files changed, 465 insertions(+), 337 deletions(-) rename packages/testing/src/execution_testing/test_types/tests/{test_block_access_lists.py => test_block_access_list_expectation.py} (82%) create mode 100644 packages/testing/src/execution_testing/test_types/tests/test_block_access_list_t8n.py diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index 6c1ca500d6..e08738c1ce 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -717,6 +717,12 @@ def generate_block_data( # tests t8n_bal = transition_tool_output.result.block_access_list bal = t8n_bal + + # Always validate BAL structural integrity (ordering, duplicates) if present + if t8n_bal is not None: + t8n_bal.validate_structure() + + # If expected BAL is defined, verify against it if ( block.expected_block_access_list is not None and t8n_bal is not None diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py b/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py index fad7017c4c..6150dfeabd 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/expectations.py @@ -18,7 +18,6 @@ BalCodeChange, BalNonceChange, BalStorageSlot, - BlockAccessListChangeLists, ) from .exceptions import BlockAccessListValidationError from .t8n import BlockAccessList @@ -179,9 +178,8 @@ def verify_against(self, actual_bal: "BlockAccessList") -> None: Verify that the actual BAL from the client matches this expected BAL. Validation steps: - 1. Validate actual BAL conforms to EIP-7928 ordering requirements - 2. Verify address expectations - presence or explicit absence - 3. Verify expected changes within accounts match actual changes + 1. Verify address expectations - presence or explicit absence + 2. Verify expected changes within accounts match actual changes Args: actual_bal: The BlockAccessList model from the client @@ -190,9 +188,6 @@ def verify_against(self, actual_bal: "BlockAccessList") -> None: BlockAccessListValidationError: If verification fails """ - # validate the actual BAL structure follows EIP-7928 ordering - self._validate_bal_ordering(actual_bal) - actual_accounts_by_addr = {acc.address: acc for acc in actual_bal.root} for address, expectation in self.account_expectations.items(): if expectation is None: @@ -236,111 +231,6 @@ def verify_against(self, actual_bal: "BlockAccessList") -> None: f"Account {address}: {str(e)}" ) from e - @staticmethod - def _validate_bal_ordering(bal: "BlockAccessList") -> None: - """ - Validate BAL ordering follows EIP-7928 requirements. - - Args: - bal: The BlockAccessList to validate - - Raises: - BlockAccessListValidationError: If ordering is invalid - - """ - # Check address ordering (ascending) - for i in range(1, len(bal.root)): - if bal.root[i - 1].address >= bal.root[i].address: - raise BlockAccessListValidationError( - f"BAL addresses are not in lexicographic order: " - f"{bal.root[i - 1].address} >= {bal.root[i].address}" - ) - - # Check transaction index ordering and uniqueness within accounts - for account in bal.root: - changes_to_check: List[tuple[str, BlockAccessListChangeLists]] = [ - ("nonce_changes", account.nonce_changes), - ("balance_changes", account.balance_changes), - ("code_changes", account.code_changes), - ] - - for field_name, change_list in changes_to_check: - if not change_list: - continue - - tx_indices = [c.tx_index for c in change_list] - - # Check both ordering and duplicates - if tx_indices != sorted(tx_indices): - raise BlockAccessListValidationError( - f"Transaction indices not in ascending order in {field_name} of account " - f"{account.address}. Got: {tx_indices}, Expected: {sorted(tx_indices)}" - ) - - if len(tx_indices) != len(set(tx_indices)): - duplicates = sorted( - { - idx - for idx in tx_indices - if tx_indices.count(idx) > 1 - } - ) - raise BlockAccessListValidationError( - f"Duplicate transaction indices in {field_name} of account " - f"{account.address}. Duplicates: {duplicates}" - ) - - # Check storage slot ordering - for i in range(1, len(account.storage_changes)): - if ( - account.storage_changes[i - 1].slot - >= account.storage_changes[i].slot - ): - raise BlockAccessListValidationError( - f"Storage slots not in ascending order in account " - f"{account.address}: {account.storage_changes[i - 1].slot} >= " - f"{account.storage_changes[i].slot}" - ) - - # Check transaction index ordering and uniqueness within storage - # slots - for storage_slot in account.storage_changes: - if not storage_slot.slot_changes: - continue - - tx_indices = [c.tx_index for c in storage_slot.slot_changes] - - # Check both ordering and duplicates - if tx_indices != sorted(tx_indices): - raise BlockAccessListValidationError( - f"Transaction indices not in ascending order in storage slot " - f"{storage_slot.slot} of account {account.address}. " - f"Got: {tx_indices}, Expected: {sorted(tx_indices)}" - ) - - if len(tx_indices) != len(set(tx_indices)): - duplicates = sorted( - { - idx - for idx in tx_indices - if tx_indices.count(idx) > 1 - } - ) - raise BlockAccessListValidationError( - f"Duplicate transaction indices in storage slot " - f"{storage_slot.slot} of account {account.address}. " - f"Duplicates: {duplicates}" - ) - - # Check storage reads ordering - for i in range(1, len(account.storage_reads)): - if account.storage_reads[i - 1] >= account.storage_reads[i]: - raise BlockAccessListValidationError( - f"Storage reads not in ascending order in account " - f"{account.address}: {account.storage_reads[i - 1]} >= " - f"{account.storage_reads[i]}" - ) - @staticmethod def _compare_account_expectations( expected: BalAccountExpectation, actual: BalAccountChange diff --git a/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py b/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py index 9a9ba84508..03b8224bbf 100644 --- a/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py +++ b/packages/testing/src/execution_testing/test_types/block_access_list/t8n.py @@ -12,6 +12,7 @@ ) from .account_changes import BalAccountChange +from .exceptions import BlockAccessListValidationError class BlockAccessList(EthereumTestRootModel[List[BalAccountChange]]): @@ -49,3 +50,108 @@ def rlp(self) -> Bytes: def rlp_hash(self) -> Bytes: """Return the hash of the RLP encoded block access list.""" return self.rlp.keccak256() + + def validate_structure(self) -> None: + """ + Validate BAL structure follows EIP-7928 requirements. + + Checks: + - Addresses are in lexicographic (ascending) order + - Transaction indices are sorted and unique within each change list + - Storage slots are in ascending order + - Storage reads are in ascending order + + Raises: + BlockAccessListValidationError: If validation fails + """ + # Check address ordering (ascending) + for i in range(1, len(self.root)): + if self.root[i - 1].address >= self.root[i].address: + raise BlockAccessListValidationError( + f"BAL addresses are not in lexicographic order: " + f"{self.root[i - 1].address} >= {self.root[i].address}" + ) + + # Check transaction index ordering and uniqueness within accounts + for account in self.root: + changes_to_check: List[tuple[str, List[Any]]] = [ + ("nonce_changes", account.nonce_changes), + ("balance_changes", account.balance_changes), + ("code_changes", account.code_changes), + ] + + for field_name, change_list in changes_to_check: + if not change_list: + continue + + tx_indices = [c.tx_index for c in change_list] + + # Check both ordering and duplicates + if tx_indices != sorted(tx_indices): + raise BlockAccessListValidationError( + f"Transaction indices not in ascending order in {field_name} of account " + f"{account.address}. Got: {tx_indices}, Expected: {sorted(tx_indices)}" + ) + + if len(tx_indices) != len(set(tx_indices)): + duplicates = sorted( + { + idx + for idx in tx_indices + if tx_indices.count(idx) > 1 + } + ) + raise BlockAccessListValidationError( + f"Duplicate transaction indices in {field_name} of account " + f"{account.address}. Duplicates: {duplicates}" + ) + + # Check storage slot ordering + for i in range(1, len(account.storage_changes)): + if ( + account.storage_changes[i - 1].slot + >= account.storage_changes[i].slot + ): + raise BlockAccessListValidationError( + f"Storage slots not in ascending order in account " + f"{account.address}: {account.storage_changes[i - 1].slot} >= " + f"{account.storage_changes[i].slot}" + ) + + # Check transaction index ordering and uniqueness within storage slots + for storage_slot in account.storage_changes: + if not storage_slot.slot_changes: + continue + + tx_indices = [c.tx_index for c in storage_slot.slot_changes] + + # Check both ordering and duplicates + if tx_indices != sorted(tx_indices): + raise BlockAccessListValidationError( + f"Transaction indices not in ascending order in storage slot " + f"{storage_slot.slot} of account {account.address}. " + f"Got: {tx_indices}, Expected: {sorted(tx_indices)}" + ) + + if len(tx_indices) != len(set(tx_indices)): + duplicates = sorted( + { + idx + for idx in tx_indices + if tx_indices.count(idx) > 1 + } + ) + raise BlockAccessListValidationError( + f"Duplicate transaction indices in storage slot " + f"{storage_slot.slot} of account {account.address}. " + f"Duplicates: {duplicates}" + ) + + # Check storage reads ordering + for i in range(1, len(account.storage_reads)): + if account.storage_reads[i - 1] >= account.storage_reads[i]: + raise BlockAccessListValidationError( + f"Storage reads not in ascending order in account " + f"{account.address}: {account.storage_reads[i - 1]} >= " + f"{account.storage_reads[i]}" + ) diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_lists.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py similarity index 82% rename from packages/testing/src/execution_testing/test_types/tests/test_block_access_lists.py rename to packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py index 8effee3688..899d9647e4 100644 --- a/packages/testing/src/execution_testing/test_types/tests/test_block_access_lists.py +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_expectation.py @@ -312,231 +312,6 @@ def test_missing_expected_address() -> None: expectation.verify_against(actual_bal) -@pytest.mark.parametrize( - "addresses,error_message", - [ - ( - [ - Address(0xB), - Address(0xA), # should come first - ], - "BAL addresses are not in lexicographic order", - ), - ( - [ - Address(0x1), - Address(0x3), - Address(0x2), - ], - "BAL addresses are not in lexicographic order", - ), - ], -) -def test_actual_bal_address_ordering_validation( - addresses: Any, error_message: str -) -> None: - """Test that actual BAL must have addresses in lexicographic order.""" - # Create BAL with addresses in the given order - actual_bal = BlockAccessList( - [ - BalAccountChange(address=addr, nonce_changes=[]) - for addr in addresses - ] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises(BlockAccessListValidationError, match=error_message): - expectation.verify_against(actual_bal) - - -@pytest.mark.parametrize( - "storage_slots,error_message", - [ - ( - [StorageKey(0x02), StorageKey(0x01)], # 0x02 before 0x01 - "Storage slots not in ascending order", - ), - ( - [StorageKey(0x01), StorageKey(0x03), StorageKey(0x02)], - "Storage slots not in ascending order", - ), - ], -) -def test_actual_bal_storage_slot_ordering( - storage_slots: Any, error_message: str -) -> None: - """Test that actual BAL must have storage slots in lexicographic order.""" - addr = Address(0xA) - - actual_bal = BlockAccessList( - [ - BalAccountChange( - address=addr, - storage_changes=[ - BalStorageSlot(slot=slot, slot_changes=[]) - for slot in storage_slots - ], - ) - ] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises(BlockAccessListValidationError, match=error_message): - expectation.verify_against(actual_bal) - - -@pytest.mark.parametrize( - "storage_reads,error_message", - [ - ( - [StorageKey(0x02), StorageKey(0x01)], - "Storage reads not in ascending order", - ), - ( - [StorageKey(0x01), StorageKey(0x03), StorageKey(0x02)], - "Storage reads not in ascending order", - ), - ], -) -def test_actual_bal_storage_reads_ordering( - storage_reads: Any, error_message: str -) -> None: - """Test that actual BAL must have storage reads in lexicographic order.""" - addr = Address(0xA) - - actual_bal = BlockAccessList( - [BalAccountChange(address=addr, storage_reads=storage_reads)] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises(BlockAccessListValidationError, match=error_message): - expectation.verify_against(actual_bal) - - -@pytest.mark.parametrize( - "field_name", - ["nonce_changes", "balance_changes", "code_changes"], -) -def test_actual_bal_tx_indices_ordering(field_name: str) -> None: - """Test that actual BAL must have tx indices in ascending order.""" - addr = Address(0xA) - - tx_indices = [2, 3, 1] # out of order - - changes: Any = [] - if field_name == "nonce_changes": - changes = [ - BalNonceChange(tx_index=idx, post_nonce=1) for idx in tx_indices - ] - elif field_name == "balance_changes": - changes = [ - BalBalanceChange(tx_index=idx, post_balance=100) - for idx in tx_indices - ] - elif field_name == "code_changes": - changes = [ - BalCodeChange(tx_index=idx, new_code=b"code") for idx in tx_indices - ] - - actual_bal = BlockAccessList( - [BalAccountChange(address=addr, **{field_name: changes})] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises( - BlockAccessListValidationError, - match="Transaction indices not in ascending order", - ): - expectation.verify_against(actual_bal) - - -@pytest.mark.parametrize( - "field_name", - ["nonce_changes", "balance_changes", "code_changes"], -) -def test_actual_bal_duplicate_tx_indices(field_name: str) -> None: - """ - Test that actual BAL must not have duplicate tx indices in change lists. - """ - addr = Address(0xA) - - # Duplicate tx_index=1 - changes: Any = [] - if field_name == "nonce_changes": - changes = [ - BalNonceChange(tx_index=1, post_nonce=1), - BalNonceChange(tx_index=1, post_nonce=2), # duplicate tx_index - BalNonceChange(tx_index=2, post_nonce=3), - ] - elif field_name == "balance_changes": - changes = [ - BalBalanceChange(tx_index=1, post_balance=100), - BalBalanceChange( - tx_index=1, post_balance=200 - ), # duplicate tx_index - BalBalanceChange(tx_index=2, post_balance=300), - ] - elif field_name == "code_changes": - changes = [ - BalCodeChange(tx_index=1, new_code=b"code1"), - BalCodeChange(tx_index=1, new_code=b""), # duplicate tx_index - BalCodeChange(tx_index=2, new_code=b"code2"), - ] - - actual_bal = BlockAccessList( - [BalAccountChange(address=addr, **{field_name: changes})] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises( - BlockAccessListValidationError, - match=f"Duplicate transaction indices in {field_name}.*Duplicates: \\[1\\]", - ): - expectation.verify_against(actual_bal) - - -def test_actual_bal_storage_duplicate_tx_indices() -> None: - """ - Test that storage changes must not have duplicate tx indices within same - slot. - """ - addr = Address(0xA) - - # Create storage changes with duplicate tx_index within the same slot - actual_bal = BlockAccessList( - [ - BalAccountChange( - address=addr, - storage_changes=[ - BalStorageSlot( - slot=0x01, - slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x100), - BalStorageChange( - tx_index=1, post_value=0x200 - ), # duplicate tx_index - BalStorageChange(tx_index=2, post_value=0x300), - ], - ) - ], - ) - ] - ) - - expectation = BlockAccessListExpectation(account_expectations={}) - - with pytest.raises( - BlockAccessListValidationError, - match="Duplicate transaction indices in storage slot.*Duplicates: \\[1\\]", - ): - expectation.verify_against(actual_bal) - - def test_expected_addresses_auto_sorted() -> None: """ Test that expected addresses are automatically sorted before comparison. diff --git a/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_t8n.py b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_t8n.py new file mode 100644 index 0000000000..3c884cf2f4 --- /dev/null +++ b/packages/testing/src/execution_testing/test_types/tests/test_block_access_list_t8n.py @@ -0,0 +1,351 @@ +""" +Tests for BlockAccessList.validate_structure() method. + +These tests verify that the BAL structural validation correctly enforces +EIP-7928 requirements for ordering and uniqueness. +""" + +from typing import List, Union + +import pytest + +from execution_testing.base_types import Address, HexNumber, StorageKey +from execution_testing.test_types.block_access_list import ( + BalAccountChange, + BalBalanceChange, + BalCodeChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + BlockAccessList, + BlockAccessListValidationError, +) + + +def test_bal_address_ordering_validation() -> None: + """Test that BAL addresses must be in lexicographic order.""" + alice = Address(0xAA) + bob = Address(0xBB) + + # Correct order: alice < bob + bal_valid = BlockAccessList( + [ + BalAccountChange(address=alice), + BalAccountChange(address=bob), + ] + ) + bal_valid.validate_structure() # Should not raise + + # Incorrect order: bob before alice + bal_invalid = BlockAccessList( + [ + BalAccountChange(address=bob), + BalAccountChange(address=alice), + ] + ) + + with pytest.raises( + BlockAccessListValidationError, + match="addresses are not in lexicographic order", + ): + bal_invalid.validate_structure() + + +def test_bal_storage_slot_ordering() -> None: + """Test that storage slots must be in ascending order.""" + addr = Address(0xA) + + # Correct order + bal_valid = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot(slot=StorageKey(0), slot_changes=[]), + BalStorageSlot(slot=StorageKey(1), slot_changes=[]), + BalStorageSlot(slot=StorageKey(2), slot_changes=[]), + ], + ) + ] + ) + bal_valid.validate_structure() # Should not raise + + # Incorrect order: slot 2 before slot 1 + bal_invalid = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot(slot=StorageKey(0), slot_changes=[]), + BalStorageSlot(slot=StorageKey(2), slot_changes=[]), + BalStorageSlot(slot=StorageKey(1), slot_changes=[]), + ], + ) + ] + ) + + with pytest.raises( + BlockAccessListValidationError, + match="Storage slots not in ascending order", + ): + bal_invalid.validate_structure() + + +def test_bal_storage_reads_ordering() -> None: + """Test that storage reads must be in ascending order.""" + addr = Address(0xA) + + # Correct order + bal_valid = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_reads=[StorageKey(0), StorageKey(1), StorageKey(2)], + ) + ] + ) + bal_valid.validate_structure() # Should not raise + + # Incorrect order + bal_invalid = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_reads=[StorageKey(0), StorageKey(2), StorageKey(1)], + ) + ] + ) + + with pytest.raises( + BlockAccessListValidationError, + match="Storage reads not in ascending order", + ): + bal_invalid.validate_structure() + + +@pytest.mark.parametrize( + "field_name", + ["nonce_changes", "balance_changes", "code_changes"], +) +def test_bal_tx_indices_ordering(field_name: str) -> None: + """ + Test that transaction indices must be in ascending order within change lists. + """ + addr = Address(0xA) + + changes_valid: List[Union[BalNonceChange, BalBalanceChange, BalCodeChange]] + changes_invalid: List[ + Union[BalNonceChange, BalBalanceChange, BalCodeChange] + ] + + # Correct order: tx_index 1, 2, 3 + if field_name == "nonce_changes": + changes_valid = [ + BalNonceChange(tx_index=HexNumber(1), post_nonce=HexNumber(1)), + BalNonceChange(tx_index=HexNumber(2), post_nonce=HexNumber(2)), + BalNonceChange(tx_index=HexNumber(3), post_nonce=HexNumber(3)), + ] + changes_invalid = [ + BalNonceChange(tx_index=HexNumber(1), post_nonce=HexNumber(1)), + BalNonceChange(tx_index=HexNumber(3), post_nonce=HexNumber(3)), + BalNonceChange(tx_index=HexNumber(2), post_nonce=HexNumber(2)), + ] + elif field_name == "balance_changes": + changes_valid = [ + BalBalanceChange( + tx_index=HexNumber(1), post_balance=HexNumber(100) + ), + BalBalanceChange( + tx_index=HexNumber(2), post_balance=HexNumber(200) + ), + BalBalanceChange( + tx_index=HexNumber(3), post_balance=HexNumber(300) + ), + ] + changes_invalid = [ + BalBalanceChange( + tx_index=HexNumber(1), post_balance=HexNumber(100) + ), + BalBalanceChange( + tx_index=HexNumber(3), post_balance=HexNumber(300) + ), + BalBalanceChange( + tx_index=HexNumber(2), post_balance=HexNumber(200) + ), + ] + elif field_name == "code_changes": + changes_valid = [ + BalCodeChange(tx_index=HexNumber(1), new_code=b"code1"), + BalCodeChange(tx_index=HexNumber(2), new_code=b"code2"), + BalCodeChange(tx_index=HexNumber(3), new_code=b"code3"), + ] + changes_invalid = [ + BalCodeChange(tx_index=HexNumber(1), new_code=b"code1"), + BalCodeChange(tx_index=HexNumber(3), new_code=b"code3"), + BalCodeChange(tx_index=HexNumber(2), new_code=b"code2"), + ] + + bal_valid = BlockAccessList( + [BalAccountChange(address=addr, **{field_name: changes_valid})] + ) + bal_valid.validate_structure() # Should not raise + + bal_invalid = BlockAccessList( + [BalAccountChange(address=addr, **{field_name: changes_invalid})] + ) + + with pytest.raises( + BlockAccessListValidationError, + match=f"Transaction indices not in ascending order in {field_name}", + ): + bal_invalid.validate_structure() + + +@pytest.mark.parametrize( + "field_name", + ["nonce_changes", "balance_changes", "code_changes"], +) +def test_bal_duplicate_tx_indices(field_name: str) -> None: + """ + Test that BAL must not have duplicate tx indices in change lists. + """ + addr = Address(0xA) + + changes: List[Union[BalNonceChange, BalBalanceChange, BalCodeChange]] + + # Duplicate tx_index=1 + if field_name == "nonce_changes": + changes = [ + BalNonceChange(tx_index=HexNumber(1), post_nonce=HexNumber(1)), + BalNonceChange( + tx_index=HexNumber(1), post_nonce=HexNumber(2) + ), # duplicate tx_index + BalNonceChange(tx_index=HexNumber(2), post_nonce=HexNumber(3)), + ] + elif field_name == "balance_changes": + changes = [ + BalBalanceChange( + tx_index=HexNumber(1), post_balance=HexNumber(100) + ), + BalBalanceChange( + tx_index=HexNumber(1), post_balance=HexNumber(200) + ), # duplicate tx_index + BalBalanceChange( + tx_index=HexNumber(2), post_balance=HexNumber(300) + ), + ] + elif field_name == "code_changes": + changes = [ + BalCodeChange(tx_index=HexNumber(1), new_code=b"code1"), + BalCodeChange( + tx_index=HexNumber(1), new_code=b"" + ), # duplicate tx_index + BalCodeChange(tx_index=HexNumber(2), new_code=b"code2"), + ] + + bal = BlockAccessList( + [BalAccountChange(address=addr, **{field_name: changes})] + ) + + with pytest.raises( + BlockAccessListValidationError, + match=f"Duplicate transaction indices in {field_name}.*Duplicates: \\[1\\]", + ): + bal.validate_structure() + + +def test_bal_storage_duplicate_tx_indices() -> None: + """ + Test that storage changes must not have duplicate tx indices within same slot. + """ + addr = Address(0xA) + + # Create storage changes with duplicate tx_index within the same slot + bal = BlockAccessList( + [ + BalAccountChange( + address=addr, + storage_changes=[ + BalStorageSlot( + slot=StorageKey(0), + slot_changes=[ + BalStorageChange( + tx_index=HexNumber(1), + post_value=StorageKey(100), + ), + BalStorageChange( + tx_index=HexNumber(1), + post_value=StorageKey(200), + ), # duplicate tx_index + BalStorageChange( + tx_index=HexNumber(2), + post_value=StorageKey(300), + ), + ], + ) + ], + ) + ] + ) + + with pytest.raises( + BlockAccessListValidationError, + match="Duplicate transaction indices in storage slot.*Duplicates: \\[1\\]", + ): + bal.validate_structure() + + +def test_bal_multiple_violations() -> None: + """ + Test that validation catches the first violation when multiple exist. + """ + alice = Address(0xAA) + bob = Address(0xBB) + + # Wrong address order AND duplicate tx indices + bal = BlockAccessList( + [ + BalAccountChange( + address=bob, # Should come after alice + nonce_changes=[ + BalNonceChange( + tx_index=HexNumber(1), post_nonce=HexNumber(1) + ), + BalNonceChange( + tx_index=HexNumber(1), post_nonce=HexNumber(2) + ), # duplicate + ], + ), + BalAccountChange(address=alice), + ] + ) + + # Should catch the first error (address ordering) + with pytest.raises( + BlockAccessListValidationError, + match="addresses are not in lexicographic order", + ): + bal.validate_structure() + + +def test_bal_empty_list_valid() -> None: + """Test that an empty BAL is valid.""" + bal = BlockAccessList([]) + bal.validate_structure() # Should not raise + + +def test_bal_single_account_valid() -> None: + """Test that a BAL with a single account is valid.""" + bal = BlockAccessList( + [ + BalAccountChange( + address=Address(0xA), + nonce_changes=[ + BalNonceChange( + tx_index=HexNumber(1), post_nonce=HexNumber(1) + ) + ], + ) + ] + ) + bal.validate_structure() # Should not raise From 5f0e971009039da4b8ccbd40c3547c9d64e812a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20Wahrst=C3=A4tter?= Date: Wed, 5 Nov 2025 12:11:41 +0100 Subject: [PATCH 11/51] feat(specs): EIP-7928 refactoring --- .../amsterdam/block_access_lists/__init__.py | 6 + .../amsterdam/block_access_lists/tracker.py | 152 +++++++++------ src/ethereum/forks/amsterdam/fork.py | 38 ++-- src/ethereum/forks/amsterdam/state.py | 112 +++++++---- src/ethereum/forks/amsterdam/vm/__init__.py | 5 + .../forks/amsterdam/vm/eoa_delegation.py | 86 ++++++--- src/ethereum/forks/amsterdam/vm/gas.py | 17 ++ .../amsterdam/vm/instructions/environment.py | 55 +++--- .../amsterdam/vm/instructions/storage.py | 80 ++++---- .../forks/amsterdam/vm/instructions/system.py | 177 +++++++++++------- .../forks/amsterdam/vm/interpreter.py | 31 ++- 11 files changed, 474 insertions(+), 285 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/__init__.py b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py index 856ab832bc..ebcda46e98 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/__init__.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py @@ -21,6 +21,9 @@ StateChangeTracker, begin_call_frame, commit_call_frame, + handle_in_transaction_selfdestruct, + normalize_balance_changes, + prepare_balance_tracking, rollback_call_frame, set_block_access_index, track_address_access, @@ -44,6 +47,9 @@ "build_block_access_list", "commit_call_frame", "compute_block_access_list_hash", + "handle_in_transaction_selfdestruct", + "normalize_balance_changes", + "prepare_balance_tracking", "rollback_call_frame", "set_block_access_index", "rlp_encode_block_access_list", diff --git a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py index 0ea945e7b1..9008a20878 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py @@ -31,6 +31,7 @@ if TYPE_CHECKING: from ..state import State # noqa: F401 + from ..vm import BlockEnvironment # noqa: F401 @dataclass @@ -114,7 +115,7 @@ class StateChangeTracker: def set_block_access_index( - tracker: StateChangeTracker, block_access_index: Uint + block_env: "BlockEnvironment", block_access_index: Uint ) -> None: """ Set the current block access index for tracking changes. @@ -129,13 +130,14 @@ def set_block_access_index( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. block_access_index : The block access index (0 for pre-execution, 1..n for transactions, n+1 for post-execution). """ + tracker = block_env.change_tracker tracker.current_block_access_index = block_access_index # Clear the pre-storage cache for each new transaction to ensure # no-op writes are detected relative to the transaction start @@ -182,7 +184,7 @@ def capture_pre_state( def track_address_access( - tracker: StateChangeTracker, address: Address + block_env: "BlockEnvironment", address: Address ) -> None: """ Track that an address was accessed. @@ -192,17 +194,19 @@ def track_address_access( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The account address that was accessed. """ - add_touched_account(tracker.block_access_list_builder, address) + add_touched_account( + block_env.change_tracker.block_access_list_builder, address + ) def track_storage_read( - tracker: StateChangeTracker, address: Address, key: Bytes32, state: "State" + block_env: "BlockEnvironment", address: Address, key: Bytes32 ) -> None: """ Track a storage read operation. @@ -213,29 +217,28 @@ def track_storage_read( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The account address whose storage is being read. key : The storage slot being read. - state : - The current execution state. """ - track_address_access(tracker, address) + track_address_access(block_env, address) - capture_pre_state(tracker, address, key, state) + capture_pre_state(block_env.change_tracker, address, key, block_env.state) - add_storage_read(tracker.block_access_list_builder, address, key) + add_storage_read( + block_env.change_tracker.block_access_list_builder, address, key + ) def track_storage_write( - tracker: StateChangeTracker, + block_env: "BlockEnvironment", address: Address, key: Bytes32, new_value: U256, - state: "State", ) -> None: """ Track a storage write operation. @@ -246,23 +249,22 @@ def track_storage_write( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The account address whose storage is being modified. key : The storage slot being written to. new_value : The new value to write. - state : - The current execution state. [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 """ - track_address_access(tracker, address) + track_address_access(block_env, address) - pre_value = capture_pre_state(tracker, address, key, state) + tracker = block_env.change_tracker + pre_value = capture_pre_state(tracker, address, key, block_env.state) value_bytes = new_value.to_be_bytes32() @@ -322,8 +324,36 @@ def capture_pre_balance( return tracker.pre_balance_cache[address] +def prepare_balance_tracking( + block_env: "BlockEnvironment", address: Address +) -> None: + """ + Prepare for tracking balance changes by caching the pre-transaction + balance. + + This should be called before any balance modifications when you need to + ensure the pre-balance is captured for later normalization. This is + particularly important for operations like withdrawals where the balance + might not actually change. + + Parameters + ---------- + block_env : + The block execution environment. + address : + The account address whose balance will be tracked. + + + """ + # Ensure the address is tracked + track_address_access(block_env, address) + + # Cache the pre-balance for later normalization + capture_pre_balance(block_env.change_tracker, address, block_env.state) + + def track_balance_change( - tracker: StateChangeTracker, + block_env: "BlockEnvironment", address: Address, new_balance: U256, ) -> None: @@ -335,16 +365,17 @@ def track_balance_change( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The account address whose balance changed. new_balance : The new balance value. """ - track_address_access(tracker, address) + track_address_access(block_env, address) + tracker = block_env.change_tracker block_access_index = BlockAccessIndex(tracker.current_block_access_index) add_balance_change( tracker.block_access_list_builder, @@ -362,7 +393,7 @@ def track_balance_change( def track_nonce_change( - tracker: StateChangeTracker, address: Address, new_nonce: Uint + block_env: "BlockEnvironment", address: Address, new_nonce: Uint ) -> None: """ Track a nonce change for an account. @@ -373,20 +404,19 @@ def track_nonce_change( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The account address whose nonce changed. new_nonce : The new nonce value. - state : - The current execution state. [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 """ - track_address_access(tracker, address) + track_address_access(block_env, address) + tracker = block_env.change_tracker block_access_index = BlockAccessIndex(tracker.current_block_access_index) nonce_u64 = U64(new_nonce) add_nonce_change( @@ -403,7 +433,7 @@ def track_nonce_change( def track_code_change( - tracker: StateChangeTracker, address: Address, new_code: Bytes + block_env: "BlockEnvironment", address: Address, new_code: Bytes ) -> None: """ Track a code change for contract deployment. @@ -414,8 +444,8 @@ def track_code_change( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The address receiving the contract code. new_code : @@ -425,7 +455,8 @@ def track_code_change( [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 """ - track_address_access(tracker, address) + track_address_access(block_env, address) + tracker = block_env.change_tracker block_access_index = BlockAccessIndex(tracker.current_block_access_index) add_code_change( tracker.block_access_list_builder, @@ -441,7 +472,7 @@ def track_code_change( def handle_in_transaction_selfdestruct( - tracker: StateChangeTracker, address: Address + block_env: "BlockEnvironment", address: Address ) -> None: """ Handle an account that self-destructed in the same transaction it was @@ -456,12 +487,13 @@ def handle_in_transaction_selfdestruct( Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. address : The address that self-destructed. """ + tracker = block_env.change_tracker builder = tracker.block_access_list_builder if address not in builder.accounts: return @@ -493,9 +525,7 @@ def handle_in_transaction_selfdestruct( ] -def normalize_balance_changes( - tracker: StateChangeTracker, state: "State" -) -> None: +def normalize_balance_changes(block_env: "BlockEnvironment") -> None: """ Normalize balance changes for the current block access index. @@ -515,15 +545,14 @@ def normalize_balance_changes( Parameters ---------- - tracker : - The state change tracker instance. - state : - The current execution state. + block_env : + The block execution environment. """ # Import locally to avoid circular import from ..state import get_account + tracker = block_env.change_tracker builder = tracker.block_access_list_builder current_index = tracker.current_block_access_index @@ -532,10 +561,10 @@ def normalize_balance_changes( account_data = builder.accounts[address] # Get the pre-transaction balance - pre_balance = capture_pre_balance(tracker, address, state) + pre_balance = capture_pre_balance(tracker, address, block_env.state) # Get the current (post-transaction) balance - post_balance = get_account(state, address).balance + post_balance = get_account(block_env.state, address).balance # If pre-tx balance equals post-tx balance, remove all balance changes # for this address in the current transaction @@ -548,7 +577,7 @@ def normalize_balance_changes( ] -def begin_call_frame(tracker: StateChangeTracker) -> None: +def begin_call_frame(block_env: "BlockEnvironment") -> None: """ Begin a new call frame for tracking reverts. @@ -557,14 +586,14 @@ def begin_call_frame(tracker: StateChangeTracker) -> None: Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. """ - tracker.call_frame_snapshots.append(CallFrameSnapshot()) + block_env.change_tracker.call_frame_snapshots.append(CallFrameSnapshot()) -def rollback_call_frame(tracker: StateChangeTracker) -> None: +def rollback_call_frame(block_env: "BlockEnvironment") -> None: """ Rollback changes from the current call frame. @@ -578,10 +607,11 @@ def rollback_call_frame(tracker: StateChangeTracker) -> None: Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. """ + tracker = block_env.change_tracker if not tracker.call_frame_snapshots: return @@ -651,7 +681,7 @@ def rollback_call_frame(tracker: StateChangeTracker) -> None: # All touched addresses remain in the access list (already tracked) -def commit_call_frame(tracker: StateChangeTracker) -> None: +def commit_call_frame(block_env: "BlockEnvironment") -> None: """ Commit changes from the current call frame. @@ -660,9 +690,9 @@ def commit_call_frame(tracker: StateChangeTracker) -> None: Parameters ---------- - tracker : - The state change tracker instance. + block_env : + The block execution environment. """ - if tracker.call_frame_snapshots: - tracker.call_frame_snapshots.pop() + if block_env.change_tracker.call_frame_snapshots: + block_env.change_tracker.call_frame_snapshots.pop() diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index c326bd1b93..4ec12acf91 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -32,9 +32,9 @@ from .block_access_lists.builder import build_block_access_list from .block_access_lists.rlp_utils import compute_block_access_list_hash from .block_access_lists.tracker import ( - capture_pre_balance, handle_in_transaction_selfdestruct, normalize_balance_changes, + prepare_balance_tracking, set_block_access_index, track_balance_change, ) @@ -781,7 +781,7 @@ def apply_body( # Set block access index for pre-execution system contracts # EIP-7928: System contracts use block_access_index 0 - set_block_access_index(block_env.state.change_tracker, Uint(0)) + set_block_access_index(block_env, Uint(0)) process_unchecked_system_transaction( block_env=block_env, @@ -800,9 +800,7 @@ def apply_body( # EIP-7928: Post-execution uses block_access_index len(transactions) + 1 post_execution_index = ulen(transactions) + Uint(1) - set_block_access_index( - block_env.state.change_tracker, post_execution_index - ) + set_block_access_index(block_env, post_execution_index) process_withdrawals(block_env, block_output, withdrawals) @@ -811,7 +809,7 @@ def apply_body( block_output=block_output, ) block_output.block_access_list = build_block_access_list( - block_env.state.change_tracker.block_access_list_builder + block_env.change_tracker.block_access_list_builder ) return block_output @@ -894,7 +892,7 @@ def process_transaction( """ # EIP-7928: Transactions use block_access_index 1 to len(transactions) # Transaction at index i gets block_access_index i+1 - set_block_access_index(block_env.state.change_tracker, index + Uint(1)) + set_block_access_index(block_env, index + Uint(1)) trie_set( block_output.transactions_trie, @@ -1020,17 +1018,12 @@ def process_transaction( # EIP-7928: In-transaction self-destruct - convert storage writes to # reads and remove nonce/code changes. Only accounts created in same # tx are in accounts_to_delete per EIP-6780. - handle_in_transaction_selfdestruct( - block_env.state.change_tracker, address - ) + handle_in_transaction_selfdestruct(block_env, address) destroy_account(block_env.state, address) # EIP-7928: Normalize balance changes for this transaction # Remove balance changes where post-tx balance equals pre-tx balance - normalize_balance_changes( - block_env.state.change_tracker, - block_env.state, - ) + normalize_balance_changes(block_env) block_output.block_gas_used += tx_gas_used_after_refund block_output.blob_gas_used += tx_blob_gas_used @@ -1070,28 +1063,21 @@ def increase_recipient_balance(recipient: Account) -> None: rlp.encode(wd), ) - # Capture pre-balance before modification (even for zero withdrawals) - # This ensures the address appears in BAL per EIP-7928 - capture_pre_balance( - block_env.state.change_tracker, wd.address, block_env.state - ) + # Prepare for balance tracking (ensures address appears in BAL and + # pre-balance is cached for normalization) + prepare_balance_tracking(block_env, wd.address) modify_state(block_env.state, wd.address, increase_recipient_balance) # Track balance change for BAL # (withdrawals are tracked as system contract changes) new_balance = get_account(block_env.state, wd.address).balance - track_balance_change( - block_env.state.change_tracker, wd.address, U256(new_balance) - ) + track_balance_change(block_env, wd.address, U256(new_balance)) # EIP-7928: Normalize balance changes for this withdrawal # Remove balance changes where post-withdrawal balance # equals pre-withdrawal balance - normalize_balance_changes( - block_env.state.change_tracker, - block_env.state, - ) + normalize_balance_changes(block_env) if account_exists_and_is_empty(block_env.state, wd.address): destroy_account(block_env.state, wd.address) diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index 3067b175d6..b47cd2d377 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -17,16 +17,14 @@ """ from dataclasses import dataclass, field -from typing import Callable, Dict, List, Optional, Set, Tuple +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Set, Tuple from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify from ethereum_types.numeric import U256, Uint -from .block_access_lists.builder import BlockAccessListBuilder from .block_access_lists.tracker import ( - StateChangeTracker, - capture_pre_balance, + prepare_balance_tracking, track_balance_change, track_code_change, track_nonce_change, @@ -34,6 +32,9 @@ from .fork_types import EMPTY_ACCOUNT, Account, Address, Root from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set +if TYPE_CHECKING: + from .vm import BlockEnvironment # noqa: F401 + @dataclass class State: @@ -54,9 +55,6 @@ class State: ] ] = field(default_factory=list) created_accounts: Set[Address] = field(default_factory=set) - change_tracker: StateChangeTracker = field( - default_factory=lambda: StateChangeTracker(BlockAccessListBuilder()) - ) @dataclass @@ -517,13 +515,17 @@ def move_ether( sender_address: Address, recipient_address: Address, amount: U256, + block_env: "BlockEnvironment" = None, ) -> None: """ Move funds between accounts. """ - # Capture pre-transaction balance before first modification - capture_pre_balance(state.change_tracker, sender_address, state) - capture_pre_balance(state.change_tracker, recipient_address, state) + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + # Prepare for balance tracking (captures pre-balance and ensures + # addresses are tracked) + prepare_balance_tracking(block_env, sender_address) + prepare_balance_tracking(block_env, recipient_address) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -536,18 +538,25 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(state, sender_address, reduce_sender_balance) modify_state(state, recipient_address, increase_recipient_balance) - sender_new_balance = get_account(state, sender_address).balance - recipient_new_balance = get_account(state, recipient_address).balance + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + sender_new_balance = get_account(state, sender_address).balance + recipient_new_balance = get_account(state, recipient_address).balance - track_balance_change( - state.change_tracker, sender_address, U256(sender_new_balance) - ) - track_balance_change( - state.change_tracker, recipient_address, U256(recipient_new_balance) - ) + track_balance_change( + block_env, sender_address, U256(sender_new_balance) + ) + track_balance_change( + block_env, recipient_address, U256(recipient_new_balance) + ) -def set_account_balance(state: State, address: Address, amount: U256) -> None: +def set_account_balance( + state: State, + address: Address, + amount: U256, + block_env: "BlockEnvironment" = None, +) -> None: """ Sets the balance of an account. @@ -562,19 +571,29 @@ def set_account_balance(state: State, address: Address, amount: U256) -> None: amount: The amount that needs to set in balance. + block_env: + Optional block environment for tracking changes. + """ - # Capture pre-transaction balance before first modification - capture_pre_balance(state.change_tracker, address, state) + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + # Prepare for balance tracking (captures pre-balance and ensures + # address is tracked) + prepare_balance_tracking(block_env, address) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) - track_balance_change(state.change_tracker, address, amount) + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + track_balance_change(block_env, address, amount) -def increment_nonce(state: State, address: Address) -> None: +def increment_nonce( + state: State, address: Address, block_env: "BlockEnvironment" = None +) -> None: """ Increments the nonce of an account. @@ -586,6 +605,9 @@ def increment_nonce(state: State, address: Address) -> None: address: Address of the account whose nonce needs to be incremented. + block_env: + Optional block environment for tracking changes. + """ def increase_nonce(sender: Account) -> None: @@ -593,18 +615,25 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) - # Track nonce change for Block Access List - # (for ALL accounts and ALL nonce changes) - # This includes: - # - EOA senders (transaction nonce increments) - # - Contracts performing CREATE/CREATE2 - # - Deployed contracts - # - EIP-7702 authorities - account = get_account(state, address) - track_nonce_change(state.change_tracker, address, account.nonce) + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + # Track nonce change for Block Access List + # (for ALL accounts and ALL nonce changes) + # This includes: + # - EOA senders (transaction nonce increments) + # - Contracts performing CREATE/CREATE2 + # - Deployed contracts + # - EIP-7702 authorities + account = get_account(state, address) + track_nonce_change(block_env, address, account.nonce) -def set_code(state: State, address: Address, code: Bytes) -> None: +def set_code( + state: State, + address: Address, + code: Bytes, + block_env: "BlockEnvironment" = None, +) -> None: """ Sets Account code. @@ -619,6 +648,9 @@ def set_code(state: State, address: Address, code: Bytes) -> None: code: The bytecode that needs to be set. + block_env: + Optional block environment for tracking changes. + """ def write_code(sender: Account) -> None: @@ -626,12 +658,14 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) - # Only track code changes if it's not setting empty code on a - # newly created address. For newly created addresses, setting - # code to b"" is not a meaningful state change since the address - # had no code to begin with. - if not (code == b"" and address in state.created_accounts): - track_code_change(state.change_tracker, address, code) + # Only track if block_env is provided (EIP-7928 tracking) + if block_env is not None: + # Only track code changes if it's not setting empty code on a + # newly created address. For newly created addresses, setting + # code to b"" is not a meaningful state change since the address + # had no code to begin with. + if not (code == b"" and address in state.created_accounts): + track_code_change(block_env, address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 7c2db77ce9..c10df4897b 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -21,7 +21,9 @@ from ethereum.crypto.hash import Hash32 from ethereum.exceptions import EthereumException +from ..block_access_lists.builder import BlockAccessListBuilder from ..block_access_lists.rlp_types import BlockAccessList +from ..block_access_lists.tracker import StateChangeTracker from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Address, Authorization, VersionedHash from ..state import State, TransientStorage @@ -48,6 +50,9 @@ class BlockEnvironment: prev_randao: Bytes32 excess_blob_gas: U64 parent_beacon_block_root: Hash32 + change_tracker: StateChangeTracker = field( + default_factory=lambda: StateChangeTracker(BlockAccessListBuilder()) + ) @dataclass diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index eca5978435..649027cb43 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -115,46 +115,92 @@ def recover_authority(authorization: Authorization) -> Address: return Address(keccak256(public_key)[12:32]) -def access_delegation( +def check_delegation( evm: Evm, address: Address -) -> Tuple[bool, Address, Bytes, Uint]: +) -> Tuple[bool, Address, Address, Bytes, Uint]: """ - Get the delegation address, code, and the cost of access from the address. + Check delegation info without modifying state or tracking. Parameters ---------- evm : `Evm` The execution frame. address : `Address` - The address to get the delegation from. + The address to check for delegation. Returns ------- - delegation : `Tuple[bool, Address, Bytes, Uint]` - The delegation address, code, and access gas cost. + delegation : `Tuple[bool, Address, Address, Bytes, Uint]` + (is_delegated, original_address, final_address, code, + additional_gas_cost) """ state = evm.message.block_env.state code = get_account(state, address).code if not is_valid_delegation(code): - return False, address, code, Uint(0) + return False, address, address, code, Uint(0) - # EIP-7928: Track the authority address (delegated account being called) - track_address_access(state.change_tracker, address) + delegated_address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) - address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) - if address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS + if delegated_address in evm.accessed_addresses: + additional_gas_cost = GAS_WARM_ACCESS else: - evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - code = get_account(state, address).code + additional_gas_cost = GAS_COLD_ACCOUNT_ACCESS + + delegated_code = get_account(state, delegated_address).code + + return ( + True, + address, + delegated_address, + delegated_code, + additional_gas_cost, + ) + + +def apply_delegation_tracking( + evm: Evm, original_address: Address, delegated_address: Address +) -> None: + """ + Apply delegation tracking after gas check passes. + + Parameters + ---------- + evm : `Evm` + The execution frame. + original_address : `Address` + The original address that was called. + delegated_address : `Address` + The address delegated to. + + """ + track_address_access(evm.message.block_env, original_address) + + if delegated_address not in evm.accessed_addresses: + evm.accessed_addresses.add(delegated_address) + + track_address_access(evm.message.block_env, delegated_address) + + +def access_delegation( + evm: Evm, address: Address +) -> Tuple[bool, Address, Bytes, Uint]: + """ + Access delegation info and track state changes. + + DEPRECATED: Use check_delegation and apply_delegation_tracking + for proper gas check ordering. + + """ + is_delegated, orig_addr, final_addr, code, gas_cost = check_delegation( + evm, address + ) - # EIP-7928: Track delegation target when loaded as call target - track_address_access(state.change_tracker, address) + if is_delegated: + apply_delegation_tracking(evm, orig_addr, final_addr) - return True, address, code, access_gas_cost + return is_delegated, final_addr, code, gas_cost def set_delegation(message: Message) -> U256: @@ -193,9 +239,7 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code - # EIP-7928: Track authority account access in BAL even if delegation - # fails - track_address_access(state.change_tracker, authority) + track_address_access(message.block_env, authority) if authority_code and not is_valid_delegation(authority_code): continue diff --git a/src/ethereum/forks/amsterdam/vm/gas.py b/src/ethereum/forks/amsterdam/vm/gas.py index 62118f4c6a..22a022a289 100644 --- a/src/ethereum/forks/amsterdam/vm/gas.py +++ b/src/ethereum/forks/amsterdam/vm/gas.py @@ -118,6 +118,23 @@ class MessageCallGas: sub_call: Uint +def check_gas(evm: Evm, amount: Uint) -> None: + """ + Checks if `amount` gas is available without charging it. + Raises OutOfGasError if insufficient gas. + + Parameters + ---------- + evm : + The current EVM. + amount : + The amount of gas to check. + + """ + if evm.gas_left < amount: + raise OutOfGasError + + def charge_gas(evm: Evm, amount: Uint) -> None: """ Subtracts `amount` from `evm.gas_left`. diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index 39b89567ff..e984d8030f 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -36,6 +36,7 @@ calculate_blob_gas_price, calculate_gas_extend_memory, charge_gas, + check_gas, ) from ..stack import pop, push @@ -77,17 +78,18 @@ def balance(evm: Evm) -> None: address = to_address_masked(pop(evm.stack)) # GAS - if address in evm.accessed_addresses: - charge_gas(evm, GAS_WARM_ACCESS) - else: + is_cold_access = address not in evm.accessed_addresses + gas_cost = GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + check_gas(evm, gas_cost) + if is_cold_access: evm.accessed_addresses.add(address) - charge_gas(evm, GAS_COLD_ACCOUNT_ACCESS) + track_address_access(evm.message.block_env, address) + charge_gas(evm, gas_cost) # OPERATION # Non-existent accounts default to EMPTY_ACCOUNT, which has balance 0. state = evm.message.block_env.state balance = get_account(state, address).balance - track_address_access(state.change_tracker, address) push(evm.stack, balance) @@ -344,18 +346,19 @@ def extcodesize(evm: Evm) -> None: address = to_address_masked(pop(evm.stack)) # GAS - if address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: + is_cold_access = address not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + ) + check_gas(evm, access_gas_cost) + if is_cold_access: evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - + track_address_access(evm.message.block_env, address) charge_gas(evm, access_gas_cost) # OPERATION state = evm.message.block_env.state code = get_account(state, address).code - track_address_access(state.change_tracker, address) codesize = U256(len(code)) push(evm.stack, codesize) @@ -387,19 +390,22 @@ def extcodecopy(evm: Evm) -> None: evm.memory, [(memory_start_index, size)] ) - if address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: - evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS + is_cold_access = address not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + ) + total_gas_cost = access_gas_cost + copy_gas_cost + extend_memory.cost - charge_gas(evm, access_gas_cost + copy_gas_cost + extend_memory.cost) + check_gas(evm, total_gas_cost) + if is_cold_access: + evm.accessed_addresses.add(address) + track_address_access(evm.message.block_env, address) + charge_gas(evm, total_gas_cost) # OPERATION evm.memory += b"\x00" * extend_memory.expand_by state = evm.message.block_env.state code = get_account(state, address).code - track_address_access(state.change_tracker, address) value = buffer_read(code, code_start_index, size) memory_write(evm.memory, memory_start_index, value) @@ -480,18 +486,19 @@ def extcodehash(evm: Evm) -> None: address = to_address_masked(pop(evm.stack)) # GAS - if address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: + is_cold_access = address not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + ) + check_gas(evm, access_gas_cost) + if is_cold_access: evm.accessed_addresses.add(address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - + track_address_access(evm.message.block_env, address) charge_gas(evm, access_gas_cost) # OPERATION state = evm.message.block_env.state account = get_account(state, address) - track_address_access(state.change_tracker, address) if account == EMPTY_ACCOUNT: codehash = U256(0) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index 35ff36bab3..65a6a38455 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -34,6 +34,7 @@ GAS_STORAGE_UPDATE, GAS_WARM_ACCESS, charge_gas, + check_gas, ) from ..stack import pop, push @@ -53,22 +54,24 @@ def sload(evm: Evm) -> None: key = pop(evm.stack).to_be_bytes32() # GAS - if (evm.message.current_target, key) in evm.accessed_storage_keys: - charge_gas(evm, GAS_WARM_ACCESS) - else: + gas_cost = ( + GAS_WARM_ACCESS + if (evm.message.current_target, key) in evm.accessed_storage_keys + else GAS_COLD_SLOAD + ) + check_gas(evm, gas_cost) + if (evm.message.current_target, key) not in evm.accessed_storage_keys: evm.accessed_storage_keys.add((evm.message.current_target, key)) - charge_gas(evm, GAS_COLD_SLOAD) - - # OPERATION - state = evm.message.block_env.state - value = get_storage(state, evm.message.current_target, key) - track_storage_read( - state.change_tracker, + evm.message.block_env, evm.message.current_target, key, - evm.message.block_env.state, ) + charge_gas(evm, gas_cost) + + # OPERATION + state = evm.message.block_env.state + value = get_storage(state, evm.message.current_target, key) push(evm.stack, value) @@ -98,19 +101,14 @@ def sstore(evm: Evm) -> None: ) current_value = get_storage(state, evm.message.current_target, key) - # Track the implicit SLOAD that occurs in SSTORE - # This must happen BEFORE charge_gas() so reads are recorded even if OOG - track_storage_read( - state.change_tracker, + # GAS + gas_cost = Uint(0) + is_cold_access = ( evm.message.current_target, key, - evm.message.block_env.state, - ) - - gas_cost = Uint(0) + ) not in evm.accessed_storage_keys - if (evm.message.current_target, key) not in evm.accessed_storage_keys: - evm.accessed_storage_keys.add((evm.message.current_target, key)) + if is_cold_access: gas_cost += GAS_COLD_SLOAD if original_value == current_value and current_value != new_value: @@ -121,7 +119,28 @@ def sstore(evm: Evm) -> None: else: gas_cost += GAS_WARM_ACCESS - # Refund Counter Calculation + check_gas(evm, gas_cost) + + if is_cold_access: + evm.accessed_storage_keys.add((evm.message.current_target, key)) + + track_storage_read( + evm.message.block_env, + evm.message.current_target, + key, + ) + track_storage_write( + evm.message.block_env, + evm.message.current_target, + key, + new_value, + ) + + charge_gas(evm, gas_cost) + if evm.message.is_static: + raise WriteInStaticContext + + # REFUND COUNTER if current_value != new_value: if original_value != 0 and current_value != 0 and new_value == 0: # Storage is cleared for the first time in the transaction @@ -142,22 +161,7 @@ def sstore(evm: Evm) -> None: GAS_STORAGE_UPDATE - GAS_COLD_SLOAD - GAS_WARM_ACCESS ) - charge_gas(evm, gas_cost) - if evm.message.is_static: - raise WriteInStaticContext - - # Track storage write BEFORE modifying state - # so we capture the correct pre-value - - track_storage_write( - state.change_tracker, - evm.message.current_target, - key, - new_value, - state, - ) - - # Now modify the storage + # OPERATION set_storage(state, evm.message.current_target, key, new_value) # PROGRAM COUNTER diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 1de3a140a1..8c1babdcd1 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -32,7 +32,10 @@ compute_create2_contract_address, to_address_masked, ) -from ...vm.eoa_delegation import access_delegation +from ...vm.eoa_delegation import ( + apply_delegation_tracking, + check_delegation, +) from .. import ( Evm, Message, @@ -53,6 +56,7 @@ calculate_gas_extend_memory, calculate_message_call_gas, charge_gas, + check_gas, init_code_cost, max_message_call_gas, ) @@ -110,8 +114,7 @@ def generic_create( evm.accessed_addresses.add(contract_address) - # Track address access for BAL - track_address_access(state.change_tracker, contract_address) + track_address_access(evm.message.block_env, contract_address) if account_has_code_or_nonce( state, contract_address @@ -389,23 +392,22 @@ def call(evm: Evm) -> None: ], ) - if to in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: - evm.accessed_addresses.add(to) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - - # Track address access for BAL - track_address_access(evm.message.block_env.state.change_tracker, to) + is_cold_access = to not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + ) - code_address = to ( - disable_precompiles, - code_address, + is_delegated, + original_address, + final_address, code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_gas_cost, + ) = check_delegation(evm, to) + access_gas_cost += delegation_gas_cost + + code_address = final_address + disable_precompiles = is_delegated create_gas_cost = GAS_NEW_ACCOUNT if value == 0 or is_account_alive(evm.message.block_env.state, to): @@ -418,6 +420,17 @@ def call(evm: Evm) -> None: extend_memory.cost, access_gas_cost + create_gas_cost + transfer_gas_cost, ) + + check_gas(evm, message_call_gas.cost + extend_memory.cost) + + if is_cold_access: + evm.accessed_addresses.add(to) + + track_address_access(evm.message.block_env, to) + + if is_delegated: + apply_delegation_tracking(evm, original_address, final_address) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) if evm.message.is_static and value != U256(0): raise WriteInStaticContext @@ -453,7 +466,7 @@ def call(evm: Evm) -> None: def callcode(evm: Evm) -> None: """ - Message-call into this account with alternative account’s code. + Message-call into this account with alternative account's code. Parameters ---------- @@ -481,24 +494,22 @@ def callcode(evm: Evm) -> None: ], ) - if code_address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: - evm.accessed_addresses.add(code_address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - - # Track address access for BAL - track_address_access( - evm.message.block_env.state.change_tracker, code_address + is_cold_access = code_address not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) ( - disable_precompiles, - code_address, + is_delegated, + original_address, + final_address, code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_gas_cost, + ) = check_delegation(evm, code_address) + access_gas_cost += delegation_gas_cost + + code_address = final_address + disable_precompiles = is_delegated transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE message_call_gas = calculate_message_call_gas( @@ -508,6 +519,17 @@ def callcode(evm: Evm) -> None: extend_memory.cost, access_gas_cost + transfer_gas_cost, ) + + check_gas(evm, message_call_gas.cost + extend_memory.cost) + + if is_cold_access: + evm.accessed_addresses.add(original_address) + + track_address_access(evm.message.block_env, original_address) + + if is_delegated: + apply_delegation_tracking(evm, original_address, final_address) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -559,15 +581,10 @@ def selfdestruct(evm: Evm) -> None: # GAS gas_cost = GAS_SELF_DESTRUCT - if beneficiary not in evm.accessed_addresses: - evm.accessed_addresses.add(beneficiary) + is_cold_access = beneficiary not in evm.accessed_addresses + if is_cold_access: gas_cost += GAS_COLD_ACCOUNT_ACCESS - # Track address access for BAL - track_address_access( - evm.message.block_env.state.change_tracker, beneficiary - ) - if ( not is_account_alive(evm.message.block_env.state, beneficiary) and get_account( @@ -577,6 +594,13 @@ def selfdestruct(evm: Evm) -> None: ): gas_cost += GAS_SELF_DESTRUCT_NEW_ACCOUNT + check_gas(evm, gas_cost) + + if is_cold_access: + evm.accessed_addresses.add(beneficiary) + + track_address_access(evm.message.block_env, beneficiary) + charge_gas(evm, gas_cost) originator = evm.message.current_target @@ -637,28 +661,37 @@ def delegatecall(evm: Evm) -> None: ], ) - if code_address in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: - evm.accessed_addresses.add(code_address) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - - # Track address access for BAL - track_address_access( - evm.message.block_env.state.change_tracker, code_address + is_cold_access = code_address not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) ( - disable_precompiles, - code_address, + is_delegated, + original_address, + final_address, code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_gas_cost, + ) = check_delegation(evm, code_address) + access_gas_cost += delegation_gas_cost + + code_address = final_address + disable_precompiles = is_delegated message_call_gas = calculate_message_call_gas( U256(0), gas, Uint(evm.gas_left), extend_memory.cost, access_gas_cost ) + + check_gas(evm, message_call_gas.cost + extend_memory.cost) + + if is_cold_access: + evm.accessed_addresses.add(original_address) + + track_address_access(evm.message.block_env, original_address) + + if is_delegated: + apply_delegation_tracking(evm, original_address, final_address) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -711,23 +744,22 @@ def staticcall(evm: Evm) -> None: ], ) - if to in evm.accessed_addresses: - access_gas_cost = GAS_WARM_ACCESS - else: - evm.accessed_addresses.add(to) - access_gas_cost = GAS_COLD_ACCOUNT_ACCESS - - # Track address access for BAL - track_address_access(evm.message.block_env.state.change_tracker, to) + is_cold_access = to not in evm.accessed_addresses + access_gas_cost = ( + GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS + ) - code_address = to ( - disable_precompiles, - code_address, + is_delegated, + original_address, + final_address, code, - delegated_access_gas_cost, - ) = access_delegation(evm, code_address) - access_gas_cost += delegated_access_gas_cost + delegation_gas_cost, + ) = check_delegation(evm, to) + access_gas_cost += delegation_gas_cost + + code_address = final_address + disable_precompiles = is_delegated message_call_gas = calculate_message_call_gas( U256(0), @@ -736,6 +768,17 @@ def staticcall(evm: Evm) -> None: extend_memory.cost, access_gas_cost, ) + + check_gas(evm, message_call_gas.cost + extend_memory.cost) + + if is_cold_access: + evm.accessed_addresses.add(to) + + track_address_access(evm.message.block_env, to) + + if is_delegated: + apply_delegation_tracking(evm, original_address, final_address) + charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index afd66169f1..5b33e48dd0 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -141,7 +141,7 @@ def process_message_call(message: Message) -> MessageCallOutput: # EIP-7928: Track delegation target when loaded as call target track_address_access( - block_env.state.change_tracker, + block_env, delegated_address, ) @@ -253,14 +253,21 @@ def process_message(message: Message) -> Evm: # take snapshot of state before processing the message begin_transaction(state, transient_storage) - if hasattr(state, "change_tracker") and state.change_tracker: - begin_call_frame(state.change_tracker) + if ( + hasattr(message.block_env, "change_tracker") + and message.block_env.change_tracker + ): + begin_call_frame(message.block_env) # Track target address access when processing a message - track_address_access(state.change_tracker, message.current_target) + track_address_access(message.block_env, message.current_target) if message.should_transfer_value and message.value != 0: move_ether( - state, message.caller, message.current_target, message.value + state, + message.caller, + message.current_target, + message.value, + message.block_env, ) evm = execute_code(message) @@ -268,12 +275,18 @@ def process_message(message: Message) -> Evm: # revert state to the last saved checkpoint # since the message call resulted in an error rollback_transaction(state, transient_storage) - if hasattr(state, "change_tracker") and state.change_tracker: - rollback_call_frame(state.change_tracker) + if ( + hasattr(message.block_env, "change_tracker") + and message.block_env.change_tracker + ): + rollback_call_frame(message.block_env) else: commit_transaction(state, transient_storage) - if hasattr(state, "change_tracker") and state.change_tracker: - commit_call_frame(state.change_tracker) + if ( + hasattr(message.block_env, "change_tracker") + and message.block_env.change_tracker + ): + commit_call_frame(message.block_env) return evm From 6c9ec93bf6cdc61cd3bcda099e57c6d04faa820f Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 5 Nov 2025 10:59:35 -0700 Subject: [PATCH 12/51] fix(spec-specs): require and use blockenv for state tracking --- src/ethereum/forks/amsterdam/fork.py | 9 +- src/ethereum/forks/amsterdam/state.py | 86 ++++++++----------- .../forks/amsterdam/vm/eoa_delegation.py | 4 +- .../amsterdam/vm/instructions/storage.py | 23 +++-- .../forks/amsterdam/vm/instructions/system.py | 4 + .../forks/amsterdam/vm/interpreter.py | 6 +- .../evm_tools/t8n/__init__.py | 7 +- 7 files changed, 65 insertions(+), 74 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 4ec12acf91..d74c8eeb80 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -923,13 +923,13 @@ def process_transaction( effective_gas_fee = tx.gas * effective_gas_price gas = tx.gas - intrinsic_gas - increment_nonce(block_env.state, sender) + increment_nonce(block_env.state, sender, block_env) sender_balance_after_gas_fee = ( Uint(sender_account.balance) - effective_gas_fee - blob_gas_fee ) set_account_balance( - block_env.state, sender, U256(sender_balance_after_gas_fee) + block_env.state, sender, U256(sender_balance_after_gas_fee), block_env ) access_list_addresses = set() @@ -995,7 +995,9 @@ def process_transaction( sender_balance_after_refund = get_account( block_env.state, sender ).balance + U256(gas_refund_amount) - set_account_balance(block_env.state, sender, sender_balance_after_refund) + set_account_balance( + block_env.state, sender, sender_balance_after_refund, block_env + ) # transfer miner fees coinbase_balance_after_mining_fee = get_account( @@ -1007,6 +1009,7 @@ def process_transaction( block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee, + block_env, ) if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index b47cd2d377..3656a386c7 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -515,17 +515,15 @@ def move_ether( sender_address: Address, recipient_address: Address, amount: U256, - block_env: "BlockEnvironment" = None, + block_env: "BlockEnvironment", ) -> None: """ Move funds between accounts. """ - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - # Prepare for balance tracking (captures pre-balance and ensures - # addresses are tracked) - prepare_balance_tracking(block_env, sender_address) - prepare_balance_tracking(block_env, recipient_address) + # Prepare for balance tracking (captures pre-balance and ensures + # addresses are tracked) + prepare_balance_tracking(block_env, sender_address) + prepare_balance_tracking(block_env, recipient_address) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -538,24 +536,20 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(state, sender_address, reduce_sender_balance) modify_state(state, recipient_address, increase_recipient_balance) - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - sender_new_balance = get_account(state, sender_address).balance - recipient_new_balance = get_account(state, recipient_address).balance + sender_new_balance = get_account(state, sender_address).balance + recipient_new_balance = get_account(state, recipient_address).balance - track_balance_change( - block_env, sender_address, U256(sender_new_balance) - ) - track_balance_change( - block_env, recipient_address, U256(recipient_new_balance) - ) + track_balance_change(block_env, sender_address, U256(sender_new_balance)) + track_balance_change( + block_env, recipient_address, U256(recipient_new_balance) + ) def set_account_balance( state: State, address: Address, amount: U256, - block_env: "BlockEnvironment" = None, + block_env: "BlockEnvironment", ) -> None: """ Sets the balance of an account. @@ -572,27 +566,23 @@ def set_account_balance( The amount that needs to set in balance. block_env: - Optional block environment for tracking changes. + Block environment for tracking changes. """ - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - # Prepare for balance tracking (captures pre-balance and ensures - # address is tracked) - prepare_balance_tracking(block_env, address) + # Prepare for balance tracking (captures pre-balance and ensures + # address is tracked) + prepare_balance_tracking(block_env, address) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - track_balance_change(block_env, address, amount) + track_balance_change(block_env, address, amount) def increment_nonce( - state: State, address: Address, block_env: "BlockEnvironment" = None + state: State, address: Address, block_env: "BlockEnvironment" ) -> None: """ Increments the nonce of an account. @@ -606,7 +596,7 @@ def increment_nonce( Address of the account whose nonce needs to be incremented. block_env: - Optional block environment for tracking changes. + Block environment for tracking changes. """ @@ -615,24 +605,22 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - # Track nonce change for Block Access List - # (for ALL accounts and ALL nonce changes) - # This includes: - # - EOA senders (transaction nonce increments) - # - Contracts performing CREATE/CREATE2 - # - Deployed contracts - # - EIP-7702 authorities - account = get_account(state, address) - track_nonce_change(block_env, address, account.nonce) + # Track nonce change for Block Access List (EIP-7928) + # (for ALL accounts and ALL nonce changes) + # This includes: + # - EOA senders (transaction nonce increments) + # - Contracts performing CREATE/CREATE2 + # - Deployed contracts + # - EIP-7702 authorities + account = get_account(state, address) + track_nonce_change(block_env, address, account.nonce) def set_code( state: State, address: Address, code: Bytes, - block_env: "BlockEnvironment" = None, + block_env: "BlockEnvironment", ) -> None: """ Sets Account code. @@ -649,7 +637,7 @@ def set_code( The bytecode that needs to be set. block_env: - Optional block environment for tracking changes. + Block environment for tracking changes. """ @@ -658,14 +646,12 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) - # Only track if block_env is provided (EIP-7928 tracking) - if block_env is not None: - # Only track code changes if it's not setting empty code on a - # newly created address. For newly created addresses, setting - # code to b"" is not a meaningful state change since the address - # had no code to begin with. - if not (code == b"" and address in state.created_accounts): - track_code_change(block_env, address, code) + # Only track code changes if it's not setting empty code on a + # newly created address (EIP-7928). For newly created addresses, setting + # code to b"" is not a meaningful state change since the address + # had no code to begin with. + if not (code == b"" and address in state.created_accounts): + track_code_change(block_env, address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 649027cb43..c831f9d337 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -255,9 +255,9 @@ def set_delegation(message: Message) -> U256: code_to_set = b"" else: code_to_set = EOA_DELEGATION_MARKER + auth.address - set_code(state, authority, code_to_set) + set_code(state, authority, code_to_set, message.block_env) - increment_nonce(state, authority) + increment_nonce(state, authority, message.block_env) if message.code_address is None: raise InvalidBlock("Invalid type 4 transaction: no target") diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index 65a6a38455..f8bf08ca29 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -100,6 +100,11 @@ def sstore(evm: Evm) -> None: state, evm.message.current_target, key ) current_value = get_storage(state, evm.message.current_target, key) + track_storage_read( + evm.message.block_env, + evm.message.current_target, + key, + ) # GAS gas_cost = Uint(0) @@ -124,18 +129,6 @@ def sstore(evm: Evm) -> None: if is_cold_access: evm.accessed_storage_keys.add((evm.message.current_target, key)) - track_storage_read( - evm.message.block_env, - evm.message.current_target, - key, - ) - track_storage_write( - evm.message.block_env, - evm.message.current_target, - key, - new_value, - ) - charge_gas(evm, gas_cost) if evm.message.is_static: raise WriteInStaticContext @@ -163,6 +156,12 @@ def sstore(evm: Evm) -> None: # OPERATION set_storage(state, evm.message.current_target, key, new_value) + track_storage_write( + evm.message.block_env, + evm.message.current_target, + key, + new_value, + ) # PROGRAM COUNTER evm.pc += Uint(1) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 8c1babdcd1..873e1cd4d8 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -122,6 +122,7 @@ def generic_create( increment_nonce( state, evm.message.current_target, + evm.message.block_env, ) push(evm.stack, U256(0)) return @@ -129,6 +130,7 @@ def generic_create( increment_nonce( state, evm.message.current_target, + evm.message.block_env, ) child_message = Message( @@ -613,6 +615,7 @@ def selfdestruct(evm: Evm) -> None: originator, beneficiary, originator_balance, + evm.message.block_env, ) # register account for deletion only if it was created @@ -624,6 +627,7 @@ def selfdestruct(evm: Evm) -> None: evm.message.block_env.state, originator, U256(0), + evm.message.block_env, ) evm.accounts_to_delete.add(originator) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 5b33e48dd0..8cd40cbce1 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -205,7 +205,7 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - increment_nonce(state, message.current_target) + increment_nonce(state, message.current_target, message.block_env) evm = process_message(message) if not evm.error: contract_code = evm.output @@ -223,7 +223,9 @@ def process_create_message(message: Message) -> Evm: evm.output = b"" evm.error = error else: - set_code(state, message.current_target, contract_code) + set_code( + state, message.current_target, contract_code, message.block_env + ) commit_transaction(state, transient_storage) else: rollback_transaction(state, transient_storage) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 6ef8d72d3b..3c07ec1d20 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -412,7 +412,6 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: ) if self.fork.is_after_fork("amsterdam"): - assert block_env.state.change_tracker is not None num_transactions = ulen( [ tx_idx @@ -423,9 +422,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: # post-execution use n + 1 post_execution_index = num_transactions + Uint(1) - self.fork.set_block_access_index( - block_env.state.change_tracker, post_execution_index - ) + self.fork.set_block_access_index(block_env, post_execution_index) if not self.fork.proof_of_stake: if self.options.state_reward is None: @@ -445,7 +442,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: if self.fork.is_after_fork("amsterdam"): block_output.block_access_list = self.fork.build_block_access_list( - block_env.state.change_tracker.block_access_list_builder + block_env.change_tracker.block_access_list_builder ) def run_blockchain_test(self) -> None: From 0fc2f692cbcdd0a6eb6e80f12ab147ea07c0963c Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 5 Nov 2025 18:08:56 -0700 Subject: [PATCH 13/51] refactor(spec-specs): track BAL changes via frames --- .../amsterdam/block_access_lists/__init__.py | 30 - .../amsterdam/block_access_lists/builder.py | 71 +- .../amsterdam/block_access_lists/rlp_utils.py | 4 +- .../amsterdam/block_access_lists/tracker.py | 698 ------------------ src/ethereum/forks/amsterdam/fork.py | 129 ++-- src/ethereum/forks/amsterdam/state.py | 72 +- src/ethereum/forks/amsterdam/state_tracker.py | 360 +++++++++ src/ethereum/forks/amsterdam/vm/__init__.py | 16 +- .../forks/amsterdam/vm/eoa_delegation.py | 17 +- .../amsterdam/vm/instructions/environment.py | 10 +- .../amsterdam/vm/instructions/storage.py | 24 +- .../forks/amsterdam/vm/instructions/system.py | 34 +- .../forks/amsterdam/vm/interpreter.py | 107 ++- .../evm_tools/loaders/fork_loader.py | 5 - .../evm_tools/t8n/__init__.py | 18 +- 15 files changed, 691 insertions(+), 904 deletions(-) delete mode 100644 src/ethereum/forks/amsterdam/block_access_lists/tracker.py create mode 100644 src/ethereum/forks/amsterdam/state_tracker.py diff --git a/src/ethereum/forks/amsterdam/block_access_lists/__init__.py b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py index ebcda46e98..a83523861a 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/__init__.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/__init__.py @@ -17,47 +17,17 @@ rlp_encode_block_access_list, validate_block_access_list_against_execution, ) -from .tracker import ( - StateChangeTracker, - begin_call_frame, - commit_call_frame, - handle_in_transaction_selfdestruct, - normalize_balance_changes, - prepare_balance_tracking, - rollback_call_frame, - set_block_access_index, - track_address_access, - track_balance_change, - track_code_change, - track_nonce_change, - track_storage_read, - track_storage_write, -) __all__ = [ "BlockAccessListBuilder", - "StateChangeTracker", "add_balance_change", "add_code_change", "add_nonce_change", "add_storage_read", "add_storage_write", "add_touched_account", - "begin_call_frame", "build_block_access_list", - "commit_call_frame", "compute_block_access_list_hash", - "handle_in_transaction_selfdestruct", - "normalize_balance_changes", - "prepare_balance_tracking", - "rollback_call_frame", - "set_block_access_index", "rlp_encode_block_access_list", - "track_address_access", - "track_balance_change", - "track_code_change", - "track_nonce_change", - "track_storage_read", - "track_storage_write", "validate_block_access_list_against_execution", ] diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index f27e26c377..07a0ffe7c5 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -14,7 +14,7 @@ """ from dataclasses import dataclass, field -from typing import Dict, List, Set +from typing import TYPE_CHECKING, Dict, List, Set from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.numeric import U64, U256 @@ -31,6 +31,9 @@ StorageChange, ) +if TYPE_CHECKING: + from ..state_tracker import StateChanges + @dataclass class AccountData: @@ -374,11 +377,11 @@ def add_touched_account( ensure_account(builder, address) -def build_block_access_list( +def _build_from_builder( builder: BlockAccessListBuilder, ) -> BlockAccessList: """ - Build the final [`BlockAccessList`] from accumulated changes. + Build the final [`BlockAccessList`] from a builder (internal helper). Constructs a deterministic block access list by sorting all accumulated changes. The resulting list is ordered by: @@ -445,3 +448,65 @@ def build_block_access_list( account_changes_list.sort(key=lambda x: x.address) return BlockAccessList(account_changes=tuple(account_changes_list)) + + +def build_block_access_list( + state_changes: "StateChanges", +) -> BlockAccessList: + """ + Build a [`BlockAccessList`] from a StateChanges frame. + + Converts the accumulated state changes from the frame-based architecture + into the final deterministic BlockAccessList format. + + Parameters + ---------- + state_changes : + The block-level StateChanges frame containing all changes from the block. + + Returns + ------- + block_access_list : + The final sorted and encoded block access list. + + [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 + [`StateChanges`]: ref:ethereum.forks.amsterdam.state_tracker.StateChanges + + """ + builder = BlockAccessListBuilder() + + # Add all touched addresses + for address in state_changes.touched_addresses: + add_touched_account(builder, address) + + # Add all storage reads + for address, slot in state_changes.storage_reads: + add_storage_read(builder, address, slot) + + # Add all storage writes + for (address, slot), ( + block_access_index, + value, + ) in state_changes.storage_writes.items(): + # Convert U256 to Bytes32 for storage + value_bytes = Bytes32(value.to_bytes(U256(32), "big")) + add_storage_write( + builder, address, slot, block_access_index, value_bytes + ) + + # Add all balance changes (balance_changes is keyed by (address, index)) + for ( + address, + block_access_index, + ), new_balance in state_changes.balance_changes.items(): + add_balance_change(builder, address, block_access_index, new_balance) + + # Add all nonce changes + for address, block_access_index, new_nonce in state_changes.nonce_changes: + add_nonce_change(builder, address, block_access_index, new_nonce) + + # Add all code changes + for address, block_access_index, new_code in state_changes.code_changes: + add_code_change(builder, address, block_access_index, new_code) + + return _build_from_builder(builder) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py index bbcf4a3d21..738abce181 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py @@ -216,10 +216,10 @@ def validate_block_access_list_against_execution( # 4. If Block Access List builder provided, validate against it # by comparing hashes if block_access_list_builder is not None: - from .builder import build_block_access_list + from .builder import _build_from_builder # Build a Block Access List from the builder - expected_block_access_list = build_block_access_list( + expected_block_access_list = _build_from_builder( block_access_list_builder ) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py b/src/ethereum/forks/amsterdam/block_access_lists/tracker.py deleted file mode 100644 index 9008a20878..0000000000 --- a/src/ethereum/forks/amsterdam/block_access_lists/tracker.py +++ /dev/null @@ -1,698 +0,0 @@ -""" -Provides state change tracking functionality for building Block -Access Lists during transaction execution. - -The tracker integrates with the EVM execution to capture all state accesses -and modifications, distinguishing between actual changes and no-op operations. -It maintains a cache of pre-state values to enable accurate change detection -throughout block execution. - -See [EIP-7928] for the full specification -[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 -""" - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Dict, List, Set, Tuple - -from ethereum_types.bytes import Bytes, Bytes32 -from ethereum_types.numeric import U64, U256, Uint - -from ..fork_types import Address -from .builder import ( - BlockAccessListBuilder, - add_balance_change, - add_code_change, - add_nonce_change, - add_storage_read, - add_storage_write, - add_touched_account, -) -from .rlp_types import BlockAccessIndex - -if TYPE_CHECKING: - from ..state import State # noqa: F401 - from ..vm import BlockEnvironment # noqa: F401 - - -@dataclass -class CallFrameSnapshot: - """ - Snapshot of block access list state for a single call frame. - - Used to track changes within a call frame to enable proper handling - of reverts as specified in EIP-7928. - """ - - touched_addresses: Set[Address] = field(default_factory=set) - """Addresses touched during this call frame.""" - - storage_writes: Dict[Tuple[Address, Bytes32], U256] = field( - default_factory=dict - ) - """Storage writes made during this call frame.""" - - balance_changes: Set[Tuple[Address, BlockAccessIndex, U256]] = field( - default_factory=set - ) - """Balance changes made during this call frame.""" - - nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]] = field( - default_factory=set - ) - """Nonce changes made during this call frame.""" - - code_changes: Set[Tuple[Address, BlockAccessIndex, Bytes]] = field( - default_factory=set - ) - """Code changes made during this call frame.""" - - -@dataclass -class StateChangeTracker: - """ - Tracks state changes during transaction execution for Block Access List - construction. - - This tracker maintains a cache of pre-state values and coordinates with - the [`BlockAccessListBuilder`] to record all state changes made during - block execution. It ensures that only actual changes (not no-op writes) - are recorded in the access list. - - [`BlockAccessListBuilder`]: - ref:ethereum.forks.amsterdam.block_access_lists.builder.BlockAccessListBuilder - """ - - block_access_list_builder: BlockAccessListBuilder - """ - The builder instance that accumulates all tracked changes. - """ - - pre_storage_cache: Dict[tuple, U256] = field(default_factory=dict) - """ - Cache of pre-transaction storage values, keyed by (address, slot) tuples. - This cache is cleared at the start of each transaction to track values - from the beginning of the current transaction. - """ - - pre_balance_cache: Dict[Address, U256] = field(default_factory=dict) - """ - Cache of pre-transaction balance values, keyed by address. - This cache is cleared at the start of each transaction and used by - normalize_balance_changes to filter out balance changes where - the final balance equals the initial balance. - """ - - current_block_access_index: Uint = Uint(0) - """ - The current block access index (0 for pre-execution, - 1..n for transactions, n+1 for post-execution). - """ - - call_frame_snapshots: List[CallFrameSnapshot] = field(default_factory=list) - """ - Stack of snapshots for nested call frames to handle reverts properly. - """ - - -def set_block_access_index( - block_env: "BlockEnvironment", block_access_index: Uint -) -> None: - """ - Set the current block access index for tracking changes. - - Must be called before processing each transaction/system contract - to ensure changes are associated with the correct block access index. - - Note: Block access indices differ from transaction indices: - - 0: Pre-execution (system contracts like beacon roots, block hashes) - - 1..n: Transactions (tx at index i gets block_access_index i+1) - - n+1: Post-execution (withdrawals, requests) - - Parameters - ---------- - block_env : - The block execution environment. - block_access_index : - The block access index (0 for pre-execution, - 1..n for transactions, n+1 for post-execution). - - """ - tracker = block_env.change_tracker - tracker.current_block_access_index = block_access_index - # Clear the pre-storage cache for each new transaction to ensure - # no-op writes are detected relative to the transaction start - tracker.pre_storage_cache.clear() - # Clear the pre-balance cache for each new transaction - tracker.pre_balance_cache.clear() - - -def capture_pre_state( - tracker: StateChangeTracker, address: Address, key: Bytes32, state: "State" -) -> U256: - """ - Capture and cache the pre-transaction value for a storage location. - - Retrieves the storage value from the beginning of the current transaction. - The value is cached within the transaction to avoid repeated lookups and - to maintain consistency across multiple accesses within the same - transaction. - - Parameters - ---------- - tracker : - The state change tracker instance. - address : - The account address containing the storage. - key : - The storage slot to read. - state : - The current execution state. - - Returns - ------- - value : - The storage value at the beginning of the current transaction. - - """ - cache_key = (address, key) - if cache_key not in tracker.pre_storage_cache: - # Import locally to avoid circular import - from ..state import get_storage - - tracker.pre_storage_cache[cache_key] = get_storage(state, address, key) - return tracker.pre_storage_cache[cache_key] - - -def track_address_access( - block_env: "BlockEnvironment", address: Address -) -> None: - """ - Track that an address was accessed. - - Records account access even when no state changes occur. This is - important for operations that read account data without modifying it. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address that was accessed. - - """ - add_touched_account( - block_env.change_tracker.block_access_list_builder, address - ) - - -def track_storage_read( - block_env: "BlockEnvironment", address: Address, key: Bytes32 -) -> None: - """ - Track a storage read operation. - - Records that a storage slot was read and captures its pre-state value. - The slot will only appear in the final access list if it wasn't also - written to during block execution. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address whose storage is being read. - key : - The storage slot being read. - - """ - track_address_access(block_env, address) - - capture_pre_state(block_env.change_tracker, address, key, block_env.state) - - add_storage_read( - block_env.change_tracker.block_access_list_builder, address, key - ) - - -def track_storage_write( - block_env: "BlockEnvironment", - address: Address, - key: Bytes32, - new_value: U256, -) -> None: - """ - Track a storage write operation. - - Records storage modifications, but only if the new value differs from - the pre-state value. No-op writes (where the value doesn't change) are - tracked as reads instead, as specified in [EIP-7928]. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address whose storage is being modified. - key : - The storage slot being written to. - new_value : - The new value to write. - - [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 - - """ - track_address_access(block_env, address) - - tracker = block_env.change_tracker - pre_value = capture_pre_state(tracker, address, key, block_env.state) - - value_bytes = new_value.to_be_bytes32() - - if pre_value != new_value: - add_storage_write( - tracker.block_access_list_builder, - address, - key, - BlockAccessIndex(tracker.current_block_access_index), - value_bytes, - ) - # Record in current call frame snapshot if exists - if tracker.call_frame_snapshots: - snapshot = tracker.call_frame_snapshots[-1] - snapshot.storage_writes[(address, key)] = new_value - else: - add_storage_read(tracker.block_access_list_builder, address, key) - - -def capture_pre_balance( - tracker: StateChangeTracker, address: Address, state: "State" -) -> U256: - """ - Capture and cache the pre-transaction balance for an account. - - This function caches the balance on first access for each address during - a transaction. It must be called before any balance modifications are made - to ensure we capture the pre-transaction balance correctly. The cache is - cleared at the beginning of each transaction. - - This is used by normalize_balance_changes to determine which balance - changes should be filtered out. - - Parameters - ---------- - tracker : - The state change tracker instance. - address : - The account address. - state : - The current execution state. - - Returns - ------- - value : - The balance at the beginning of the current transaction. - - """ - if address not in tracker.pre_balance_cache: - # Import locally to avoid circular import - from ..state import get_account - - # Cache the current balance on first access - # This should be called before any balance modifications - account = get_account(state, address) - tracker.pre_balance_cache[address] = account.balance - return tracker.pre_balance_cache[address] - - -def prepare_balance_tracking( - block_env: "BlockEnvironment", address: Address -) -> None: - """ - Prepare for tracking balance changes by caching the pre-transaction - balance. - - This should be called before any balance modifications when you need to - ensure the pre-balance is captured for later normalization. This is - particularly important for operations like withdrawals where the balance - might not actually change. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address whose balance will be tracked. - - - """ - # Ensure the address is tracked - track_address_access(block_env, address) - - # Cache the pre-balance for later normalization - capture_pre_balance(block_env.change_tracker, address, block_env.state) - - -def track_balance_change( - block_env: "BlockEnvironment", - address: Address, - new_balance: U256, -) -> None: - """ - Track a balance change for an account. - - Records the new balance after any balance-affecting operation, including - transfers, gas payments, block rewards, and withdrawals. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address whose balance changed. - new_balance : - The new balance value. - - """ - track_address_access(block_env, address) - - tracker = block_env.change_tracker - block_access_index = BlockAccessIndex(tracker.current_block_access_index) - add_balance_change( - tracker.block_access_list_builder, - address, - block_access_index, - new_balance, - ) - - # Record in current call frame snapshot if exists - if tracker.call_frame_snapshots: - snapshot = tracker.call_frame_snapshots[-1] - snapshot.balance_changes.add( - (address, block_access_index, new_balance) - ) - - -def track_nonce_change( - block_env: "BlockEnvironment", address: Address, new_nonce: Uint -) -> None: - """ - Track a nonce change for an account. - - Records nonce increments for both EOAs (when sending transactions) and - contracts (when performing [`CREATE`] or [`CREATE2`] operations). Deployed - contracts also have their initial nonce tracked. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The account address whose nonce changed. - new_nonce : - The new nonce value. - - [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create - [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 - - """ - track_address_access(block_env, address) - tracker = block_env.change_tracker - block_access_index = BlockAccessIndex(tracker.current_block_access_index) - nonce_u64 = U64(new_nonce) - add_nonce_change( - tracker.block_access_list_builder, - address, - block_access_index, - nonce_u64, - ) - - # Record in current call frame snapshot if exists - if tracker.call_frame_snapshots: - snapshot = tracker.call_frame_snapshots[-1] - snapshot.nonce_changes.add((address, block_access_index, nonce_u64)) - - -def track_code_change( - block_env: "BlockEnvironment", address: Address, new_code: Bytes -) -> None: - """ - Track a code change for contract deployment. - - Records new contract code deployments via [`CREATE`], [`CREATE2`], or - [`SETCODE`] operations. This function is called when contract bytecode - is deployed to an address. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The address receiving the contract code. - new_code : - The deployed contract bytecode. - - [`CREATE`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create - [`CREATE2`]: ref:ethereum.forks.amsterdam.vm.instructions.system.create2 - - """ - track_address_access(block_env, address) - tracker = block_env.change_tracker - block_access_index = BlockAccessIndex(tracker.current_block_access_index) - add_code_change( - tracker.block_access_list_builder, - address, - block_access_index, - new_code, - ) - - # Record in current call frame snapshot if exists - if tracker.call_frame_snapshots: - snapshot = tracker.call_frame_snapshots[-1] - snapshot.code_changes.add((address, block_access_index, new_code)) - - -def handle_in_transaction_selfdestruct( - block_env: "BlockEnvironment", address: Address -) -> None: - """ - Handle an account that self-destructed in the same transaction it was - created. - - Per EIP-7928, accounts destroyed within their creation transaction must be - included as read-only with storage writes converted to reads. Nonce and - code changes from the current transaction are also removed. - - Note: Balance changes are handled separately by - normalize_balance_changes. - - Parameters - ---------- - block_env : - The block execution environment. - address : - The address that self-destructed. - - """ - tracker = block_env.change_tracker - builder = tracker.block_access_list_builder - if address not in builder.accounts: - return - - account_data = builder.accounts[address] - current_index = tracker.current_block_access_index - - # Convert storage writes from current tx to reads - for slot in list(account_data.storage_changes.keys()): - account_data.storage_changes[slot] = [ - c - for c in account_data.storage_changes[slot] - if c.block_access_index != current_index - ] - if not account_data.storage_changes[slot]: - del account_data.storage_changes[slot] - account_data.storage_reads.add(slot) - - # Remove nonce and code changes from current transaction - account_data.nonce_changes = [ - c - for c in account_data.nonce_changes - if c.block_access_index != current_index - ] - account_data.code_changes = [ - c - for c in account_data.code_changes - if c.block_access_index != current_index - ] - - -def normalize_balance_changes(block_env: "BlockEnvironment") -> None: - """ - Normalize balance changes for the current block access index. - - This method filters out spurious balance changes by removing all balance - changes for addresses where the post-execution balance equals the - pre-execution balance. - - This is crucial for handling cases like: - - In-transaction self-destructs where an account with 0 balance is created - and destroyed, resulting in no net balance change - - Round-trip transfers where an account receives and sends equal amounts - - Zero-amount withdrawals where the balance doesn't actually change - - This should be called at the end of any operation that tracks balance - changes (transactions, withdrawals, etc.). Only actual state changes are - recorded in the Block Access List. - - Parameters - ---------- - block_env : - The block execution environment. - - """ - # Import locally to avoid circular import - from ..state import get_account - - tracker = block_env.change_tracker - builder = tracker.block_access_list_builder - current_index = tracker.current_block_access_index - - # Check each address that had balance changes in this transaction - for address in list(builder.accounts.keys()): - account_data = builder.accounts[address] - - # Get the pre-transaction balance - pre_balance = capture_pre_balance(tracker, address, block_env.state) - - # Get the current (post-transaction) balance - post_balance = get_account(block_env.state, address).balance - - # If pre-tx balance equals post-tx balance, remove all balance changes - # for this address in the current transaction - if pre_balance == post_balance: - # Filter out balance changes from the current transaction - account_data.balance_changes = [ - change - for change in account_data.balance_changes - if change.block_access_index != current_index - ] - - -def begin_call_frame(block_env: "BlockEnvironment") -> None: - """ - Begin a new call frame for tracking reverts. - - Creates a new snapshot to track changes within this call frame. - This allows proper handling of reverts as specified in EIP-7928. - - Parameters - ---------- - block_env : - The block execution environment. - - """ - block_env.change_tracker.call_frame_snapshots.append(CallFrameSnapshot()) - - -def rollback_call_frame(block_env: "BlockEnvironment") -> None: - """ - Rollback changes from the current call frame. - - When a call reverts, this function: - - Converts storage writes to reads - - Removes balance, nonce, and code changes - - Preserves touched addresses - - This implements EIP-7928 revert handling where reverted writes - become reads and addresses remain in the access list. - - Parameters - ---------- - block_env : - The block execution environment. - - """ - tracker = block_env.change_tracker - if not tracker.call_frame_snapshots: - return - - snapshot = tracker.call_frame_snapshots.pop() - builder = tracker.block_access_list_builder - - # Convert storage writes to reads - for (address, slot), _ in snapshot.storage_writes.items(): - # Remove the write from storage_changes - if address in builder.accounts: - account_data = builder.accounts[address] - if slot in account_data.storage_changes: - # Filter out changes from this call frame - account_data.storage_changes[slot] = [ - change - for change in account_data.storage_changes[slot] - if change.block_access_index - != tracker.current_block_access_index - ] - if not account_data.storage_changes[slot]: - del account_data.storage_changes[slot] - # Add as a read instead - account_data.storage_reads.add(slot) - - # Remove balance changes from this call frame - for address, block_access_index, new_balance in snapshot.balance_changes: - if address in builder.accounts: - account_data = builder.accounts[address] - # Filter out balance changes from this call frame - account_data.balance_changes = [ - change - for change in account_data.balance_changes - if not ( - change.block_access_index == block_access_index - and change.post_balance == new_balance - ) - ] - - # Remove nonce changes from this call frame - for address, block_access_index, new_nonce in snapshot.nonce_changes: - if address in builder.accounts: - account_data = builder.accounts[address] - # Filter out nonce changes from this call frame - account_data.nonce_changes = [ - change - for change in account_data.nonce_changes - if not ( - change.block_access_index == block_access_index - and change.new_nonce == new_nonce - ) - ] - - # Remove code changes from this call frame - for address, block_access_index, new_code in snapshot.code_changes: - if address in builder.accounts: - account_data = builder.accounts[address] - # Filter out code changes from this call frame - account_data.code_changes = [ - change - for change in account_data.code_changes - if not ( - change.block_access_index == block_access_index - and change.new_code == new_code - ) - ] - - # All touched addresses remain in the access list (already tracked) - - -def commit_call_frame(block_env: "BlockEnvironment") -> None: - """ - Commit changes from the current call frame. - - Removes the current call frame snapshot without rolling back changes. - Called when a call completes successfully. - - Parameters - ---------- - block_env : - The block execution environment. - - """ - if block_env.change_tracker.call_frame_snapshots: - block_env.change_tracker.call_frame_snapshots.pop() diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index d74c8eeb80..86a7089a30 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -16,7 +16,7 @@ from ethereum_rlp import rlp from ethereum_types.bytes import Bytes -from ethereum_types.numeric import U64, U256, Uint, ulen +from ethereum_types.numeric import U64, U256, Uint from ethereum.crypto.hash import Hash32, keccak256 from ethereum.exceptions import ( @@ -30,14 +30,8 @@ from . import vm from .block_access_lists.builder import build_block_access_list +from .block_access_lists.rlp_types import BlockAccessIndex from .block_access_lists.rlp_utils import compute_block_access_list_hash -from .block_access_lists.tracker import ( - handle_in_transaction_selfdestruct, - normalize_balance_changes, - prepare_balance_tracking, - set_block_access_index, - track_balance_change, -) from .blocks import Block, Header, Log, Receipt, Withdrawal, encode_receipt from .bloom import logs_bloom from .exceptions import ( @@ -70,6 +64,11 @@ set_account_balance, state_root, ) +from .state_tracker import ( + create_child_frame, + handle_in_transaction_selfdestruct, + normalize_balance_changes_for_transaction, +) from .transactions import ( AccessListTransaction, BlobTransaction, @@ -779,9 +778,9 @@ def apply_body( """ block_output = vm.BlockOutput() - # Set block access index for pre-execution system contracts # EIP-7928: System contracts use block_access_index 0 - set_block_access_index(block_env, Uint(0)) + # The block frame already starts at index 0, so system transactions + # naturally use that index through the block frame process_unchecked_system_transaction( block_env=block_env, @@ -798,9 +797,10 @@ def apply_body( for i, tx in enumerate(map(decode_transaction, transactions)): process_transaction(block_env, block_output, tx, Uint(i)) - # EIP-7928: Post-execution uses block_access_index len(transactions) + 1 - post_execution_index = ulen(transactions) + Uint(1) - set_block_access_index(block_env, post_execution_index) + # EIP-7928: Increment block frame to post-execution index + # After N transactions, block frame is at index N + # Post-execution operations (withdrawals, etc.) use index N+1 + block_env.block_state_changes.increment_index() process_withdrawals(block_env, block_output, withdrawals) @@ -808,8 +808,9 @@ def apply_body( block_env=block_env, block_output=block_output, ) + # Build block access list from block_env.block_state_changes block_output.block_access_list = build_block_access_list( - block_env.change_tracker.block_access_list_builder + block_env.block_state_changes ) return block_output @@ -890,9 +891,19 @@ def process_transaction( Index of the transaction in the block. """ - # EIP-7928: Transactions use block_access_index 1 to len(transactions) - # Transaction at index i gets block_access_index i+1 - set_block_access_index(block_env, index + Uint(1)) + # EIP-7928: Create a transaction-level StateChanges frame + # The frame will read the current block_access_index from the block frame + # Before transaction starts, increment block index so it's ready + block_env.block_state_changes.increment_index() + tx_state_changes = create_child_frame(block_env.block_state_changes) + + coinbase_pre_balance = get_account( + block_env.state, block_env.coinbase + ).balance + tx_state_changes.track_address(block_env.coinbase) + tx_state_changes.capture_pre_balance( + block_env.coinbase, coinbase_pre_balance + ) trie_set( block_output.transactions_trie, @@ -923,13 +934,16 @@ def process_transaction( effective_gas_fee = tx.gas * effective_gas_price gas = tx.gas - intrinsic_gas - increment_nonce(block_env.state, sender, block_env) + increment_nonce(block_env.state, sender, tx_state_changes) sender_balance_after_gas_fee = ( Uint(sender_account.balance) - effective_gas_fee - blob_gas_fee ) set_account_balance( - block_env.state, sender, U256(sender_balance_after_gas_fee), block_env + block_env.state, + sender, + U256(sender_balance_after_gas_fee), + tx_state_changes, ) access_list_addresses = set() @@ -967,6 +981,8 @@ def process_transaction( ) message = prepare_message(block_env, tx_env, tx) + # Set transaction frame so call frames become children of it + message.transaction_state_changes = tx_state_changes tx_output = process_message_call(message) @@ -996,7 +1012,7 @@ def process_transaction( block_env.state, sender ).balance + U256(gas_refund_amount) set_account_balance( - block_env.state, sender, sender_balance_after_refund, block_env + block_env.state, sender, sender_balance_after_refund, tx_state_changes ) # transfer miner fees @@ -1009,7 +1025,7 @@ def process_transaction( block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee, - block_env, + tx_state_changes, ) if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( @@ -1017,17 +1033,6 @@ def process_transaction( ): destroy_account(block_env.state, block_env.coinbase) - for address in tx_output.accounts_to_delete: - # EIP-7928: In-transaction self-destruct - convert storage writes to - # reads and remove nonce/code changes. Only accounts created in same - # tx are in accounts_to_delete per EIP-6780. - handle_in_transaction_selfdestruct(block_env, address) - destroy_account(block_env.state, address) - - # EIP-7928: Normalize balance changes for this transaction - # Remove balance changes where post-tx balance equals pre-tx balance - normalize_balance_changes(block_env) - block_output.block_gas_used += tx_gas_used_after_refund block_output.blob_gas_used += tx_blob_gas_used @@ -1046,6 +1051,34 @@ def process_transaction( block_output.block_logs += tx_output.logs + # Merge transaction frame into block frame + tx_state_changes.merge_on_success() + + # EIP-7928: Handle in-transaction self-destruct AFTER merge + # Convert storage writes to reads and remove nonce/code changes + # Only accounts created in same tx are in accounts_to_delete per EIP-6780 + + for address in tx_output.accounts_to_delete: + handle_in_transaction_selfdestruct( + block_env.block_state_changes, + address, + BlockAccessIndex( + block_env.block_state_changes.get_block_access_index() + ), + ) + destroy_account(block_env.state, address) + + # EIP-7928: Normalize balance changes for this transaction + # Remove balance changes where post-tx balance equals pre-tx balance + + normalize_balance_changes_for_transaction( + block_env.block_state_changes, + BlockAccessIndex( + block_env.block_state_changes.get_block_access_index() + ), + block_env.state, + ) + def process_withdrawals( block_env: vm.BlockEnvironment, @@ -1055,6 +1088,11 @@ def process_withdrawals( """ Increase the balance of the withdrawing account. """ + withdrawal_addresses = {wd.address for wd in withdrawals} + for address in withdrawal_addresses: + pre_balance = get_account(block_env.state, address).balance + block_env.block_state_changes.track_address(address) + block_env.block_state_changes.capture_pre_balance(address, pre_balance) def increase_recipient_balance(recipient: Account) -> None: recipient.balance += wd.amount * U256(10**9) @@ -1066,25 +1104,28 @@ def increase_recipient_balance(recipient: Account) -> None: rlp.encode(wd), ) - # Prepare for balance tracking (ensures address appears in BAL and - # pre-balance is cached for normalization) - prepare_balance_tracking(block_env, wd.address) - modify_state(block_env.state, wd.address, increase_recipient_balance) - # Track balance change for BAL - # (withdrawals are tracked as system contract changes) + # Track balance change for BAL (withdrawals use post-execution index) new_balance = get_account(block_env.state, wd.address).balance - track_balance_change(block_env, wd.address, U256(new_balance)) - - # EIP-7928: Normalize balance changes for this withdrawal - # Remove balance changes where post-withdrawal balance - # equals pre-withdrawal balance - normalize_balance_changes(block_env) + block_env.block_state_changes.track_balance_change( + wd.address, new_balance + ) if account_exists_and_is_empty(block_env.state, wd.address): destroy_account(block_env.state, wd.address) + # EIP-7928: Normalize balance changes after all withdrawals + # Filters out net-zero changes + + normalize_balance_changes_for_transaction( + block_env.block_state_changes, + BlockAccessIndex( + block_env.block_state_changes.get_block_access_index() + ), + block_env.state, + ) + def check_gas_limit(gas_limit: Uint, parent_gas_limit: Uint) -> bool: """ diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index 3656a386c7..326595ac93 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -21,15 +21,10 @@ from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify -from ethereum_types.numeric import U256, Uint - -from .block_access_lists.tracker import ( - prepare_balance_tracking, - track_balance_change, - track_code_change, - track_nonce_change, -) +from ethereum_types.numeric import U64, U256, Uint + from .fork_types import EMPTY_ACCOUNT, Account, Address, Root +from .state_tracker import StateChanges from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set if TYPE_CHECKING: @@ -515,15 +510,18 @@ def move_ether( sender_address: Address, recipient_address: Address, amount: U256, - block_env: "BlockEnvironment", + state_changes: StateChanges, ) -> None: """ Move funds between accounts. """ - # Prepare for balance tracking (captures pre-balance and ensures - # addresses are tracked) - prepare_balance_tracking(block_env, sender_address) - prepare_balance_tracking(block_env, recipient_address) + sender_balance = get_account(state, sender_address).balance + recipient_balance = get_account(state, recipient_address).balance + + state_changes.track_address(sender_address) + state_changes.capture_pre_balance(sender_address, sender_balance) + state_changes.track_address(recipient_address) + state_changes.capture_pre_balance(recipient_address, recipient_balance) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -539,9 +537,11 @@ def increase_recipient_balance(recipient: Account) -> None: sender_new_balance = get_account(state, sender_address).balance recipient_new_balance = get_account(state, recipient_address).balance - track_balance_change(block_env, sender_address, U256(sender_new_balance)) - track_balance_change( - block_env, recipient_address, U256(recipient_new_balance) + state_changes.track_balance_change( + sender_address, U256(sender_new_balance) + ) + state_changes.track_balance_change( + recipient_address, U256(recipient_new_balance) ) @@ -549,7 +549,7 @@ def set_account_balance( state: State, address: Address, amount: U256, - block_env: "BlockEnvironment", + state_changes: StateChanges, ) -> None: """ Sets the balance of an account. @@ -565,24 +565,26 @@ def set_account_balance( amount: The amount that needs to set in balance. - block_env: - Block environment for tracking changes. + state_changes: + State changes frame for tracking (EIP-7928). """ - # Prepare for balance tracking (captures pre-balance and ensures - # address is tracked) - prepare_balance_tracking(block_env, address) + current_balance = get_account(state, address).balance + + state_changes.track_address(address) + state_changes.capture_pre_balance(address, current_balance) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) - - track_balance_change(block_env, address, amount) + state_changes.track_balance_change(address, amount) def increment_nonce( - state: State, address: Address, block_env: "BlockEnvironment" + state: State, + address: Address, + state_changes: "StateChanges", ) -> None: """ Increments the nonce of an account. @@ -595,8 +597,8 @@ def increment_nonce( address: Address of the account whose nonce needs to be incremented. - block_env: - Block environment for tracking changes. + state_changes: + State changes frame for tracking (EIP-7928). """ @@ -606,21 +608,15 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) # Track nonce change for Block Access List (EIP-7928) - # (for ALL accounts and ALL nonce changes) - # This includes: - # - EOA senders (transaction nonce increments) - # - Contracts performing CREATE/CREATE2 - # - Deployed contracts - # - EIP-7702 authorities account = get_account(state, address) - track_nonce_change(block_env, address, account.nonce) + state_changes.track_nonce_change(address, U64(account.nonce)) def set_code( state: State, address: Address, code: Bytes, - block_env: "BlockEnvironment", + state_changes: StateChanges, ) -> None: """ Sets Account code. @@ -636,8 +632,8 @@ def set_code( code: The bytecode that needs to be set. - block_env: - Block environment for tracking changes. + state_changes: + State changes frame for tracking (EIP-7928). """ @@ -651,7 +647,7 @@ def write_code(sender: Account) -> None: # code to b"" is not a meaningful state change since the address # had no code to begin with. if not (code == b"" and address in state.created_accounts): - track_code_change(block_env, address, code) + state_changes.track_code_change(address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py new file mode 100644 index 0000000000..7ee77259b1 --- /dev/null +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -0,0 +1,360 @@ +""" +Hierarchical state change tracking for EIP-7928 Block Access Lists. + +Implements a frame-based hierarchy: Block → Transaction → Call frames. +Each frame tracks state changes and merges upward on completion: +- Success: merge all changes (reads + writes) +- Failure: merge only reads (writes discarded) + +Frame Hierarchy: + Block Frame: Root, lifetime = entire block, index 0..N+1 + Transaction Frame: Child of block, lifetime = single transaction + Call Frame: Child of transaction/call, lifetime = single message + +Block Access Index: 0=pre-exec, 1..N=transactions, N+1=post-exec +Stored in root frame, accessed by walking parent chain. + +Pre-State Tracking: Values captured before modifications to enable +net-zero filtering. + +[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 +""" + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple + +from ethereum_types.bytes import Bytes, Bytes32 +from ethereum_types.numeric import U64, U256, Uint + +from .block_access_lists.rlp_types import BlockAccessIndex +from .fork_types import Address + +if TYPE_CHECKING: + from .state import State + + +@dataclass +class StateChanges: + """ + Tracks state changes within a single execution frame. + + Frames form a hierarchy and merge changes upward on completion. + """ + + parent: Optional["StateChanges"] = None + _block_access_index: BlockAccessIndex = BlockAccessIndex(0) + + touched_addresses: Set[Address] = field(default_factory=set) + storage_reads: Set[Tuple[Address, Bytes32]] = field(default_factory=set) + storage_writes: Dict[ + Tuple[Address, Bytes32], Tuple[BlockAccessIndex, U256] + ] = field(default_factory=dict) + + balance_changes: Dict[Tuple[Address, BlockAccessIndex], U256] = field( + default_factory=dict + ) + nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]] = field( + default_factory=set + ) + code_changes: Set[Tuple[Address, BlockAccessIndex, Bytes]] = field( + default_factory=set + ) + + # Pre-state captures for net-zero filtering + pre_balances: Dict[Address, U256] = field(default_factory=dict) + pre_nonces: Dict[Address, U64] = field(default_factory=dict) + pre_storage: Dict[Tuple[Address, Bytes32], U256] = field( + default_factory=dict + ) + + def get_block_access_index(self) -> BlockAccessIndex: + """Get current block access index by walking to root.""" + current = self + while current.parent is not None: + current = current.parent + return current._block_access_index + + def capture_pre_balance(self, address: Address, balance: U256) -> None: + """Capture pre-balance (first-write-wins for net-zero filtering).""" + if address not in self.pre_balances: + self.pre_balances[address] = balance + + def capture_pre_nonce(self, address: Address, nonce: U64) -> None: + """Capture pre-nonce (first-write-wins).""" + if address not in self.pre_nonces: + self.pre_nonces[address] = nonce + + def capture_pre_storage( + self, address: Address, key: Bytes32, value: U256 + ) -> None: + """Capture pre-storage (first-write-wins for noop filtering).""" + slot = (address, key) + if slot not in self.pre_storage: + self.pre_storage[slot] = value + + def track_address(self, address: Address) -> None: + """Track that an address was accessed.""" + self.touched_addresses.add(address) + + def track_storage_read(self, address: Address, key: Bytes32) -> None: + """Track a storage read operation.""" + self.storage_reads.add((address, key)) + + def track_storage_write( + self, address: Address, key: Bytes32, value: U256 + ) -> None: + """Track a storage write operation with block access index.""" + self.storage_writes[(address, key)] = ( + self.get_block_access_index(), + value, + ) + + def track_balance_change( + self, address: Address, new_balance: U256 + ) -> None: + """Track balance change keyed by (address, index).""" + self.balance_changes[(address, self.get_block_access_index())] = ( + new_balance + ) + + def track_nonce_change(self, address: Address, new_nonce: U64) -> None: + """Track a nonce change.""" + self.nonce_changes.add( + (address, self.get_block_access_index(), new_nonce) + ) + + def track_code_change(self, address: Address, new_code: Bytes) -> None: + """Track a code change.""" + self.code_changes.add( + (address, self.get_block_access_index(), new_code) + ) + + def increment_index(self) -> None: + """Increment block access index by walking to root.""" + root = self + while root.parent is not None: + root = root.parent + root._block_access_index = BlockAccessIndex( + root._block_access_index + Uint(1) + ) + + def merge_on_success(self) -> None: + """ + Merge this frame's changes into parent on successful completion. + + Merges all tracked changes (reads and writes) from this frame + into the parent frame. Filters out net-zero changes based on + captured pre-state values by comparing initial vs final values. + """ + if self.parent is None: + return + + # Merge address accesses + self.parent.touched_addresses.update(self.touched_addresses) + + # Merge pre-state captures for transaction-level normalization + # Only if parent doesn't have value (first capture wins) + for addr, balance in self.pre_balances.items(): + if addr not in self.parent.pre_balances: + self.parent.pre_balances[addr] = balance + for addr, nonce in self.pre_nonces.items(): + if addr not in self.parent.pre_nonces: + self.parent.pre_nonces[addr] = nonce + for slot, value in self.pre_storage.items(): + if slot not in self.parent.pre_storage: + self.parent.pre_storage[slot] = value + + # Merge storage operations, filtering noop writes + self.parent.storage_reads.update(self.storage_reads) + for (addr, key), (idx, value) in self.storage_writes.items(): + # Only merge if value actually changed from pre-state + if (addr, key) in self.pre_storage: + if self.pre_storage[(addr, key)] != value: + self.parent.storage_writes[(addr, key)] = (idx, value) + # If equal, it's a noop write - convert to read only + else: + self.parent.storage_reads.add((addr, key)) + else: + # No pre-state captured, merge as-is + self.parent.storage_writes[(addr, key)] = (idx, value) + + # Merge balance changes - filter net-zero changes + # balance_changes keyed by (address, index) + for (addr, idx), final_balance in self.balance_changes.items(): + if addr in self.pre_balances: + if self.pre_balances[addr] != final_balance: + # Net change occurred - merge the final balance + self.parent.balance_changes[(addr, idx)] = final_balance + # else: Net-zero change - skip entirely + else: + # No pre-balance captured, merge as-is + self.parent.balance_changes[(addr, idx)] = final_balance + + # Merge nonce changes - keep only highest nonce per address + # Nonces are monotonically increasing, so just keep the max + address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} + for addr, idx, nonce in self.nonce_changes: + # Keep the highest nonce value for each address + if ( + addr not in address_final_nonces + or nonce > address_final_nonces[addr][1] + ): + address_final_nonces[addr] = (idx, nonce) + + # Merge final nonces (no net-zero filtering - nonces never decrease) + for addr, (idx, final_nonce) in address_final_nonces.items(): + self.parent.nonce_changes.add((addr, idx, final_nonce)) + + # Merge code changes - keep only latest code per address + address_final_code: Dict[Address, Tuple[BlockAccessIndex, Bytes]] = {} + for addr, idx, code in self.code_changes: + # Keep the change with highest index (most recent) + if ( + addr not in address_final_code + or idx >= address_final_code[addr][0] + ): + address_final_code[addr] = (idx, code) + + # Merge final code changes + for addr, (idx, final_code) in address_final_code.items(): + self.parent.code_changes.add((addr, idx, final_code)) + + def merge_on_failure(self) -> None: + """ + Merge this frame's changes into parent on failed completion. + + Merges only read operations from this frame into the parent. + Write operations are discarded since the frame reverted. + This is called when a call frame fails/reverts. + """ + if self.parent is None: + return + + # Only merge reads and address accesses on failure + self.parent.touched_addresses.update(self.touched_addresses) + self.parent.storage_reads.update(self.storage_reads) + + # Convert writes to reads (failed writes still accessed the slots) + for address, key in self.storage_writes.keys(): + self.parent.storage_reads.add((address, key)) + + # Note: balance_changes, nonce_changes, and code_changes are NOT + # merged on failure - they are discarded + + +def handle_in_transaction_selfdestruct( + state_changes: StateChanges, + address: Address, + current_block_access_index: BlockAccessIndex, +) -> None: + """ + Handle account self-destructed in same transaction as creation. + + Per EIP-7928 and EIP-6780, accounts destroyed within their creation + transaction must have: + - Nonce changes from current transaction removed + - Code changes from current transaction removed + - Storage writes from current transaction converted to reads + - Balance changes handled by net-zero filtering + + Parameters + ---------- + state_changes : StateChanges + The state changes tracker (typically the block-level frame). + address : Address + The address that self-destructed. + current_block_access_index : BlockAccessIndex + The current block access index (transaction index). + + """ + # Remove nonce changes from current transaction + state_changes.nonce_changes = { + (addr, idx, nonce) + for addr, idx, nonce in state_changes.nonce_changes + if not (addr == address and idx == current_block_access_index) + } + + # Remove code changes from current transaction + state_changes.code_changes = { + (addr, idx, code) + for addr, idx, code in state_changes.code_changes + if not (addr == address and idx == current_block_access_index) + } + + # Convert storage writes from current transaction to reads + for (addr, key), (idx, _value) in list( + state_changes.storage_writes.items() + ): + if addr == address and idx == current_block_access_index: + del state_changes.storage_writes[(addr, key)] + state_changes.storage_reads.add((addr, key)) + + +def normalize_balance_changes_for_transaction( + block_frame: StateChanges, + current_block_access_index: BlockAccessIndex, + state: "State", +) -> None: + """ + Normalize balance changes for the current transaction. + + Removes balance changes where post-transaction balance equals + pre-transaction balance. This handles net-zero transfers across + the entire transaction. + + This function should be called after merging transaction frames + into the block frame to filter out addresses where balance didn't + actually change from transaction start to transaction end. + + Parameters + ---------- + block_frame : StateChanges + The block-level state changes frame. + current_block_access_index : BlockAccessIndex + The current transaction's block access index. + state : State + The current state to read final balances from. + + """ + # Import locally to avoid circular import + from .state import get_account + + # Collect addresses that have balance changes in this transaction + addresses_to_check = [ + addr + for (addr, idx) in block_frame.balance_changes.keys() + if idx == current_block_access_index + ] + + # For each address, compare pre vs post balance + for addr in addresses_to_check: + if addr in block_frame.pre_balances: + pre_balance = block_frame.pre_balances[addr] + post_balance = get_account(state, addr).balance + + if pre_balance == post_balance: + # Remove balance change for this address - net-zero transfer + del block_frame.balance_changes[ + (addr, current_block_access_index) + ] + + +def create_child_frame(parent: StateChanges) -> StateChanges: + """ + Create a child frame for nested execution. + + The child frame will dynamically read the block_access_index from + the root (block) frame, ensuring all frames see the same current index. + + Parameters + ---------- + parent : StateChanges + The parent frame. + + Returns + ------- + child : StateChanges + A new child frame with parent link. + + """ + return StateChanges(parent=parent) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index c10df4897b..04b74eee9e 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -21,12 +21,11 @@ from ethereum.crypto.hash import Hash32 from ethereum.exceptions import EthereumException -from ..block_access_lists.builder import BlockAccessListBuilder from ..block_access_lists.rlp_types import BlockAccessList -from ..block_access_lists.tracker import StateChangeTracker from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Address, Authorization, VersionedHash from ..state import State, TransientStorage +from ..state_tracker import StateChanges from ..transactions import LegacyTransaction from ..trie import Trie @@ -50,8 +49,8 @@ class BlockEnvironment: prev_randao: Bytes32 excess_blob_gas: U64 parent_beacon_block_root: Hash32 - change_tracker: StateChangeTracker = field( - default_factory=lambda: StateChangeTracker(BlockAccessListBuilder()) + block_state_changes: StateChanges = field( + default_factory=lambda: StateChanges() ) @@ -143,6 +142,7 @@ class Message: accessed_storage_keys: Set[Tuple[Address, Bytes32]] disable_precompiles: bool parent_evm: Optional["Evm"] + transaction_state_changes: Optional[StateChanges] = None @dataclass @@ -165,6 +165,7 @@ class Evm: error: Optional[EthereumException] accessed_addresses: Set[Address] accessed_storage_keys: Set[Tuple[Address, Bytes32]] + state_changes: StateChanges def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: @@ -186,6 +187,9 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: evm.accessed_addresses.update(child_evm.accessed_addresses) evm.accessed_storage_keys.update(child_evm.accessed_storage_keys) + # Merge state changes from successful child frame (EIP-7928) + child_evm.state_changes.merge_on_success() + def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ @@ -200,3 +204,7 @@ def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ evm.gas_left += child_evm.gas_left + + # Merge state changes from failed child frame (EIP-7928) + # Only reads are merged, writes are discarded + child_evm.state_changes.merge_on_failure() diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index c831f9d337..4f2d5d5f1c 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -12,7 +12,7 @@ from ethereum.crypto.hash import keccak256 from ethereum.exceptions import InvalidBlock, InvalidSignatureError -from ..block_access_lists.tracker import track_address_access +# track_address_access removed - now using state_changes.track_address() from ..fork_types import Address, Authorization from ..state import account_exists, get_account, increment_nonce, set_code from ..utils.hexadecimal import hex_to_address @@ -175,12 +175,12 @@ def apply_delegation_tracking( The address delegated to. """ - track_address_access(evm.message.block_env, original_address) + evm.state_changes.track_address(original_address) if delegated_address not in evm.accessed_addresses: evm.accessed_addresses.add(delegated_address) - track_address_access(evm.message.block_env, delegated_address) + evm.state_changes.track_address(delegated_address) def access_delegation( @@ -239,7 +239,7 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code - track_address_access(message.block_env, authority) + message.block_env.block_state_changes.track_address(authority) if authority_code and not is_valid_delegation(authority_code): continue @@ -255,9 +255,14 @@ def set_delegation(message: Message) -> U256: code_to_set = b"" else: code_to_set = EOA_DELEGATION_MARKER + auth.address - set_code(state, authority, code_to_set, message.block_env) - increment_nonce(state, authority, message.block_env) + # Use transaction frame, not block frame (EIP-7928) + state_changes = ( + message.transaction_state_changes + or message.block_env.block_state_changes + ) + set_code(state, authority, code_to_set, state_changes) + increment_nonce(state, authority, state_changes) if message.code_address is None: raise InvalidBlock("Invalid type 4 transaction: no target") diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index e984d8030f..dae8c20280 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -17,7 +17,7 @@ from ethereum.crypto.hash import keccak256 from ethereum.utils.numeric import ceil32 -from ...block_access_lists.tracker import track_address_access +# track_address_access removed - now using state_changes.track_address() from ...fork_types import EMPTY_ACCOUNT from ...state import get_account from ...utils.address import to_address_masked @@ -83,7 +83,7 @@ def balance(evm: Evm) -> None: check_gas(evm, gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address_access(evm.message.block_env, address) + evm.state_changes.track_address(address) charge_gas(evm, gas_cost) # OPERATION @@ -353,7 +353,7 @@ def extcodesize(evm: Evm) -> None: check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address_access(evm.message.block_env, address) + evm.state_changes.track_address(address) charge_gas(evm, access_gas_cost) # OPERATION @@ -399,7 +399,7 @@ def extcodecopy(evm: Evm) -> None: check_gas(evm, total_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address_access(evm.message.block_env, address) + evm.state_changes.track_address(address) charge_gas(evm, total_gas_cost) # OPERATION @@ -493,7 +493,7 @@ def extcodehash(evm: Evm) -> None: check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - track_address_access(evm.message.block_env, address) + evm.state_changes.track_address(address) charge_gas(evm, access_gas_cost) # OPERATION diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index f8bf08ca29..1709dab5d7 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -13,10 +13,6 @@ from ethereum_types.numeric import Uint -from ...block_access_lists.tracker import ( - track_storage_read, - track_storage_write, -) from ...state import ( get_storage, get_storage_original, @@ -62,8 +58,7 @@ def sload(evm: Evm) -> None: check_gas(evm, gas_cost) if (evm.message.current_target, key) not in evm.accessed_storage_keys: evm.accessed_storage_keys.add((evm.message.current_target, key)) - track_storage_read( - evm.message.block_env, + evm.state_changes.track_storage_read( evm.message.current_target, key, ) @@ -100,11 +95,6 @@ def sstore(evm: Evm) -> None: state, evm.message.current_target, key ) current_value = get_storage(state, evm.message.current_target, key) - track_storage_read( - evm.message.block_env, - evm.message.current_target, - key, - ) # GAS gas_cost = Uint(0) @@ -124,6 +114,15 @@ def sstore(evm: Evm) -> None: else: gas_cost += GAS_WARM_ACCESS + # Track storage access BEFORE checking gas (EIP-7928) + # Even if we run out of gas, the access attempt should be tracked + evm.state_changes.capture_pre_storage( + evm.message.current_target, key, current_value + ) + evm.state_changes.track_storage_read( + evm.message.current_target, + key, + ) check_gas(evm, gas_cost) if is_cold_access: @@ -156,8 +155,7 @@ def sstore(evm: Evm) -> None: # OPERATION set_storage(state, evm.message.current_target, key, new_value) - track_storage_write( - evm.message.block_env, + evm.state_changes.track_storage_write( evm.message.current_target, key, new_value, diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 873e1cd4d8..e193b539de 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -16,7 +16,7 @@ from ethereum.utils.numeric import ceil32 -from ...block_access_lists.tracker import track_address_access +# track_address_access removed - now using state_changes.track_address() from ...fork_types import Address from ...state import ( account_has_code_or_nonce, @@ -114,7 +114,7 @@ def generic_create( evm.accessed_addresses.add(contract_address) - track_address_access(evm.message.block_env, contract_address) + evm.state_changes.track_address(contract_address) if account_has_code_or_nonce( state, contract_address @@ -122,7 +122,7 @@ def generic_create( increment_nonce( state, evm.message.current_target, - evm.message.block_env, + evm.state_changes, ) push(evm.stack, U256(0)) return @@ -130,7 +130,7 @@ def generic_create( increment_nonce( state, evm.message.current_target, - evm.message.block_env, + evm.state_changes, ) child_message = Message( @@ -327,6 +327,8 @@ def generic_call( evm.memory, memory_input_start_position, memory_input_size ) + # EIP-7928: Child message inherits transaction_state_changes from parent + # The actual child frame will be created automatically in process_message child_message = Message( block_env=evm.message.block_env, tx_env=evm.message.tx_env, @@ -345,6 +347,7 @@ def generic_call( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=disable_precompiles, parent_evm=evm, + transaction_state_changes=evm.message.transaction_state_changes, ) child_evm = process_message(child_message) @@ -428,7 +431,7 @@ def call(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(to) - track_address_access(evm.message.block_env, to) + evm.state_changes.track_address(to) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -527,7 +530,7 @@ def callcode(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(original_address) - track_address_access(evm.message.block_env, original_address) + evm.state_changes.track_address(original_address) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -539,6 +542,15 @@ def callcode(evm: Evm) -> None: sender_balance = get_account( evm.message.block_env.state, evm.message.current_target ).balance + + # EIP-7928: For CALLCODE with value transfer, capture pre-balance + # in parent frame. CALLCODE transfers value from/to current_target + # (same address), affecting current storage context, not child frame + if value != 0 and sender_balance >= value: + evm.state_changes.capture_pre_balance( + evm.message.current_target, sender_balance + ) + if sender_balance < value: push(evm.stack, U256(0)) evm.return_data = b"" @@ -601,7 +613,7 @@ def selfdestruct(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(beneficiary) - track_address_access(evm.message.block_env, beneficiary) + evm.state_changes.track_address(beneficiary) charge_gas(evm, gas_cost) @@ -615,7 +627,7 @@ def selfdestruct(evm: Evm) -> None: originator, beneficiary, originator_balance, - evm.message.block_env, + evm.state_changes, ) # register account for deletion only if it was created @@ -627,7 +639,7 @@ def selfdestruct(evm: Evm) -> None: evm.message.block_env.state, originator, U256(0), - evm.message.block_env, + evm.state_changes, ) evm.accounts_to_delete.add(originator) @@ -691,7 +703,7 @@ def delegatecall(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(original_address) - track_address_access(evm.message.block_env, original_address) + evm.state_changes.track_address(original_address) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -778,7 +790,7 @@ def staticcall(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(to) - track_address_access(evm.message.block_env, to) + evm.state_changes.track_address(to) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 8cd40cbce1..ab07912389 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -29,12 +29,6 @@ evm_trace, ) -from ..block_access_lists.tracker import ( - begin_call_frame, - commit_call_frame, - rollback_call_frame, - track_address_access, -) from ..blocks import Log from ..fork_types import Address from ..state import ( @@ -50,6 +44,7 @@ rollback_transaction, set_code, ) +from ..state_tracker import StateChanges, create_child_frame from ..vm import Message from ..vm.eoa_delegation import get_delegated_code_address, set_delegation from ..vm.gas import GAS_CODE_DEPOSIT, charge_gas @@ -72,6 +67,59 @@ MAX_INIT_CODE_SIZE = 2 * MAX_CODE_SIZE +def create_call_frame(parent_frame: StateChanges) -> StateChanges: + """ + Create a child frame for call-level state tracking. + + Used for contract calls (CALL, DELEGATECALL, STATICCALL, etc.) where + state changes need to be isolated and potentially reverted. + + Parameters + ---------- + parent_frame : + The parent frame (transaction or another call frame). + + Returns + ------- + call_frame : StateChanges + A new child frame linked to the parent. + + """ + return create_child_frame(parent_frame) + + +def get_message_state_frame(message: Message) -> StateChanges: + """ + Determine and create the appropriate state tracking frame for a message. + + Frame selection logic: + - Nested calls: Create child of parent EVM's frame + - Top-level calls: Create child of transaction frame + - System transactions: Use block frame directly (no isolation needed) + + Parameters + ---------- + message : + The message being processed. + + Returns + ------- + state_frame : StateChanges + The state tracking frame to use for this message execution. + + """ + if message.parent_evm is not None: + # Nested call - create child of parent EVM's frame + return create_call_frame(message.parent_evm.state_changes) + elif message.transaction_state_changes is not None: + # Top-level transaction call - create child of transaction frame + # This ensures contract execution is isolated and can be reverted + return create_call_frame(message.transaction_state_changes) + else: + # System transaction - use block frame directly + return message.block_env.block_state_changes + + @dataclass class MessageCallOutput: """ @@ -140,9 +188,8 @@ def process_message_call(message: Message) -> MessageCallOutput: message.code_address = delegated_address # EIP-7928: Track delegation target when loaded as call target - track_address_access( - block_env, - delegated_address, + message.block_env.block_state_changes.track_address( + delegated_address ) evm = process_message(message) @@ -205,7 +252,9 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - increment_nonce(state, message.current_target, message.block_env) + increment_nonce( + state, message.current_target, message.block_env.block_state_changes + ) evm = process_message(message) if not evm.error: contract_code = evm.output @@ -224,7 +273,10 @@ def process_create_message(message: Message) -> Evm: evm.error = error else: set_code( - state, message.current_target, contract_code, message.block_env + state, + message.current_target, + contract_code, + message.block_env.block_state_changes, ) commit_transaction(state, transient_storage) else: @@ -252,16 +304,10 @@ def process_message(message: Message) -> Evm: if message.depth > STACK_DEPTH_LIMIT: raise StackDepthLimitError("Stack depth limit reached") - # take snapshot of state before processing the message begin_transaction(state, transient_storage) - if ( - hasattr(message.block_env, "change_tracker") - and message.block_env.change_tracker - ): - begin_call_frame(message.block_env) - # Track target address access when processing a message - track_address_access(message.block_env, message.current_target) + state_changes = get_message_state_frame(message) + state_changes.track_address(message.current_target) if message.should_transfer_value and message.value != 0: move_ether( @@ -269,30 +315,24 @@ def process_message(message: Message) -> Evm: message.caller, message.current_target, message.value, - message.block_env, + state_changes, ) - evm = execute_code(message) + evm = execute_code(message, state_changes) if evm.error: # revert state to the last saved checkpoint # since the message call resulted in an error rollback_transaction(state, transient_storage) - if ( - hasattr(message.block_env, "change_tracker") - and message.block_env.change_tracker - ): - rollback_call_frame(message.block_env) + # Merge call frame state changes into parent + evm.state_changes.merge_on_failure() else: commit_transaction(state, transient_storage) - if ( - hasattr(message.block_env, "change_tracker") - and message.block_env.change_tracker - ): - commit_call_frame(message.block_env) + # Merge call frame state changes into parent + evm.state_changes.merge_on_success() return evm -def execute_code(message: Message) -> Evm: +def execute_code(message: Message, state_changes: StateChanges) -> Evm: """ Executes bytecode present in the `message`. @@ -300,6 +340,8 @@ def execute_code(message: Message) -> Evm: ---------- message : Transaction specific items. + state_changes : + The state changes frame to use for tracking. Returns ------- @@ -327,6 +369,7 @@ def execute_code(message: Message) -> Evm: error=None, accessed_addresses=message.accessed_addresses, accessed_storage_keys=message.accessed_storage_keys, + state_changes=state_changes, ) try: if evm.message.code_address in PRE_COMPILED_CONTRACTS: diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 2761ed6e66..8f4a9db3b2 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -132,11 +132,6 @@ def compute_block_access_list_hash(self) -> Any: "block_access_lists" ).compute_block_access_list_hash - @property - def set_block_access_index(self) -> Any: - """set_block_access_index function of the fork.""" - return self._module("block_access_lists").set_block_access_index - @property def signing_hash_2930(self) -> Any: """signing_hash_2930 function of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 3c07ec1d20..2100983144 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -10,7 +10,7 @@ from typing import Any, Final, TextIO, Tuple, Type, TypeVar from ethereum_rlp import rlp -from ethereum_types.numeric import U64, U256, Uint, ulen +from ethereum_types.numeric import U64, U256, Uint from typing_extensions import override from ethereum import trace @@ -411,18 +411,9 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: f"Transaction {original_idx} failed: {e!r}" ) + # Post-execution operations use index N+1 if self.fork.is_after_fork("amsterdam"): - num_transactions = ulen( - [ - tx_idx - for tx_idx in self.txs.successfully_parsed - if tx_idx is not None - ] - ) - - # post-execution use n + 1 - post_execution_index = num_transactions + Uint(1) - self.fork.set_block_access_index(block_env, post_execution_index) + block_env.block_state_changes.increment_index() if not self.fork.proof_of_stake: if self.options.state_reward is None: @@ -441,8 +432,9 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: self.fork.process_general_purpose_requests(block_env, block_output) if self.fork.is_after_fork("amsterdam"): + # Build block access list from block_env.block_state_changes block_output.block_access_list = self.fork.build_block_access_list( - block_env.change_tracker.block_access_list_builder + block_env.block_state_changes ) def run_blockchain_test(self) -> None: From 6aae4bb4a2b5f002bd4ea43867b39db634786552 Mon Sep 17 00:00:00 2001 From: fselmo Date: Tue, 11 Nov 2025 09:26:14 -0700 Subject: [PATCH 14/51] fix(spec-specs): Mark original addr warm before delegation Co-authored-by: spencer spencer.taylor-brown@ethereum.org --- .../forks/amsterdam/vm/instructions/system.py | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index e193b539de..136f194655 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -401,6 +401,8 @@ def call(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + if is_cold_access: + evm.accessed_addresses.add(to) ( is_delegated, @@ -428,11 +430,7 @@ def call(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - if is_cold_access: - evm.accessed_addresses.add(to) - evm.state_changes.track_address(to) - if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -503,6 +501,8 @@ def callcode(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + if is_cold_access: + evm.accessed_addresses.add(code_address) ( is_delegated, @@ -527,11 +527,7 @@ def callcode(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - if is_cold_access: - evm.accessed_addresses.add(original_address) - evm.state_changes.track_address(original_address) - if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -681,6 +677,8 @@ def delegatecall(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + if is_cold_access: + evm.accessed_addresses.add(code_address) ( is_delegated, @@ -700,11 +698,7 @@ def delegatecall(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - if is_cold_access: - evm.accessed_addresses.add(original_address) - evm.state_changes.track_address(original_address) - if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -764,6 +758,8 @@ def staticcall(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + if is_cold_access: + evm.accessed_addresses.add(to) ( is_delegated, @@ -787,11 +783,7 @@ def staticcall(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - if is_cold_access: - evm.accessed_addresses.add(to) - evm.state_changes.track_address(to) - if is_delegated: apply_delegation_tracking(evm, original_address, final_address) From 338e7331eebc00df6370036b92a94487a5d6681e Mon Sep 17 00:00:00 2001 From: fselmo Date: Tue, 11 Nov 2025 15:56:19 -0700 Subject: [PATCH 15/51] fix(spec-specs): Make sure we account for no changes --- .../forks/amsterdam/block_access_lists/builder.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index 07a0ffe7c5..843cc42c4e 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -483,11 +483,18 @@ def build_block_access_list( for address, slot in state_changes.storage_reads: add_storage_read(builder, address, slot) - # Add all storage writes + # Add all storage writes, filtering net-zero changes for (address, slot), ( block_access_index, value, ) in state_changes.storage_writes.items(): + # Check if this is a net-zero change by comparing with pre-state + if (address, slot) in state_changes.pre_storage: + if state_changes.pre_storage[(address, slot)] == value: + # Net-zero change - convert to read only + add_storage_read(builder, address, slot) + continue + # Convert U256 to Bytes32 for storage value_bytes = Bytes32(value.to_bytes(U256(32), "big")) add_storage_write( From e405848f0965f58a3cecbcd9982fec6dce1e49d5 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 12 Nov 2025 11:40:18 -0700 Subject: [PATCH 16/51] fix(spec-specs): Better tracking for code changes; ensure with BAL test --- .../amsterdam/block_access_lists/builder.py | 5 +- src/ethereum/forks/amsterdam/state.py | 8 +-- src/ethereum/forks/amsterdam/state_tracker.py | 48 ++++++++-------- .../forks/amsterdam/vm/eoa_delegation.py | 3 + .../test_block_access_lists_eip7702.py | 55 +++++++++++++++++++ .../test_cases.md | 1 + 6 files changed, 89 insertions(+), 31 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index 843cc42c4e..3e1870b0b5 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -513,7 +513,10 @@ def build_block_access_list( add_nonce_change(builder, address, block_access_index, new_nonce) # Add all code changes - for address, block_access_index, new_code in state_changes.code_changes: + for ( + address, + block_access_index, + ), new_code in state_changes.code_changes.items(): add_code_change(builder, address, block_access_index, new_code) return _build_from_builder(builder) diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index 326595ac93..8f58b6e815 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -641,13 +641,7 @@ def write_code(sender: Account) -> None: sender.code = code modify_state(state, address, write_code) - - # Only track code changes if it's not setting empty code on a - # newly created address (EIP-7928). For newly created addresses, setting - # code to b"" is not a meaningful state change since the address - # had no code to begin with. - if not (code == b"" and address in state.created_accounts): - state_changes.track_code_change(address, code) + state_changes.track_code_change(address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 7ee77259b1..42203d5e8d 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -56,8 +56,8 @@ class StateChanges: nonce_changes: Set[Tuple[Address, BlockAccessIndex, U64]] = field( default_factory=set ) - code_changes: Set[Tuple[Address, BlockAccessIndex, Bytes]] = field( - default_factory=set + code_changes: Dict[Tuple[Address, BlockAccessIndex], Bytes] = field( + default_factory=dict ) # Pre-state captures for net-zero filtering @@ -66,6 +66,7 @@ class StateChanges: pre_storage: Dict[Tuple[Address, Bytes32], U256] = field( default_factory=dict ) + pre_code: Dict[Address, Bytes] = field(default_factory=dict) def get_block_access_index(self) -> BlockAccessIndex: """Get current block access index by walking to root.""" @@ -92,6 +93,11 @@ def capture_pre_storage( if slot not in self.pre_storage: self.pre_storage[slot] = value + def capture_pre_code(self, address: Address, code: Bytes) -> None: + """Capture pre-code (first-write-wins).""" + if address not in self.pre_code: + self.pre_code[address] = code + def track_address(self, address: Address) -> None: """Track that an address was accessed.""" self.touched_addresses.add(address) @@ -125,9 +131,7 @@ def track_nonce_change(self, address: Address, new_nonce: U64) -> None: def track_code_change(self, address: Address, new_code: Bytes) -> None: """Track a code change.""" - self.code_changes.add( - (address, self.get_block_access_index(), new_code) - ) + self.code_changes[(address, self.get_block_access_index())] = new_code def increment_index(self) -> None: """Increment block access index by walking to root.""" @@ -163,6 +167,9 @@ def merge_on_success(self) -> None: for slot, value in self.pre_storage.items(): if slot not in self.parent.pre_storage: self.parent.pre_storage[slot] = value + for addr, code in self.pre_code.items(): + if addr not in self.parent.pre_code: + self.parent.pre_code[addr] = code # Merge storage operations, filtering noop writes self.parent.storage_reads.update(self.storage_reads) @@ -205,19 +212,17 @@ def merge_on_success(self) -> None: for addr, (idx, final_nonce) in address_final_nonces.items(): self.parent.nonce_changes.add((addr, idx, final_nonce)) - # Merge code changes - keep only latest code per address - address_final_code: Dict[Address, Tuple[BlockAccessIndex, Bytes]] = {} - for addr, idx, code in self.code_changes: - # Keep the change with highest index (most recent) - if ( - addr not in address_final_code - or idx >= address_final_code[addr][0] - ): - address_final_code[addr] = (idx, code) - - # Merge final code changes - for addr, (idx, final_code) in address_final_code.items(): - self.parent.code_changes.add((addr, idx, final_code)) + # Merge code changes - filter net-zero changes + # code_changes keyed by (address, index) + for (addr, idx), final_code in self.code_changes.items(): + if addr in self.pre_code: + if self.pre_code[addr] != final_code: + # Net change occurred - merge the final code + self.parent.code_changes[(addr, idx)] = final_code + # else: Net-zero change - skip entirely + else: + # No pre-code captured, merge as-is + self.parent.code_changes[(addr, idx)] = final_code def merge_on_failure(self) -> None: """ @@ -275,11 +280,8 @@ def handle_in_transaction_selfdestruct( } # Remove code changes from current transaction - state_changes.code_changes = { - (addr, idx, code) - for addr, idx, code in state_changes.code_changes - if not (addr == address and idx == current_block_access_index) - } + if (address, current_block_access_index) in state_changes.code_changes: + del state_changes.code_changes[(address, current_block_access_index)] # Convert storage writes from current transaction to reads for (addr, key), (idx, _value) in list( diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 4f2d5d5f1c..bce49462f2 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -261,6 +261,9 @@ def set_delegation(message: Message) -> U256: message.transaction_state_changes or message.block_env.block_state_changes ) + + # Capture pre-code just before setting to enable no-op filtering + state_changes.capture_pre_code(authority, authority_code) set_code(state, authority, code_to_set, state_changes) increment_nonce(state, authority, state_changes) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py index 5ff9e7b135..2a315b990f 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py @@ -608,3 +608,58 @@ def test_bal_7702_delegated_via_call_opcode( blocks=[block], post=post, ) + + +def test_bal_7702_null_address_delegation_no_code_change( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL does not record spurious code changes when delegating to + NULL_ADDRESS (sets code to empty on an account that already has + empty code). + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + + tx = Transaction( + sender=alice, + to=bob, + value=10, + gas_limit=1_000_000, + authorization_list=[ + AuthorizationTuple( + address=0, + nonce=1, + signer=alice, + ) + ], + ) + + # `alice` should appear in BAL with nonce change only, NOT code change + # because setting code from b"" to b"" is a net-zero change + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + code_changes=[], # explicit check for no code changes + ), + bob: BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + ), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=2, code=b""), + bob: Account(balance=10), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 351395479d..34fa49528d 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -39,6 +39,7 @@ | `test_bal_7702_invalid_nonce_authorization` | Ensure BAL handles failed authorization due to wrong nonce | `Relayer` sends sponsored transaction to Bob (10 wei transfer succeeds) but Alice's authorization to delegate to `Oracle` uses incorrect nonce, causing silent authorization failure | BAL **MUST** include Alice with empty changes (account access), Bob with `balance_changes` (receives 10 wei), Relayer with `nonce_changes`. **MUST NOT** include `Oracle` (authorization failed, no delegation) | ✅ Completed | | `test_bal_7702_invalid_chain_id_authorization` | Ensure BAL handles failed authorization due to wrong chain id | `Relayer` sends sponsored transaction to Bob (10 wei transfer succeeds) but Alice's authorization to delegate to `Oracle` uses incorrect chain id, causing authorization failure before account access | BAL **MUST** include Bob with `balance_changes` (receives 10 wei), Relayer with `nonce_changes`. **MUST NOT** include Alice (authorization fails before loading account) or `Oracle` (authorization failed, no delegation) | ✅ Completed | | `test_bal_7702_delegated_via_call_opcode` | Ensure BAL captures delegation target when a contract uses *CALL opcodes to call a delegated account | Pre-deployed contract `Alice` delegated to `Oracle`. `Caller` contract uses CALL/CALLCODE/DELEGATECALL/STATICCALL to call `Alice`. Bob sends transaction to `Caller`. | BAL **MUST** include Bob: `nonce_changes`. `Caller`: empty changes (account access). `Alice`: empty changes (account access - delegated account being called). `Oracle`: empty changes (delegation target access). | ✅ Completed | +| `test_bal_7702_null_address_delegation` | Ensure BAL does not record spurious code changes for net-zero code operations | Alice sends transaction with authorization delegating to NULL_ADDRESS (0x0), which sets code to `b""` on an account that already has `b""` code. Transaction sends 10 wei to Bob. | BAL **MUST** include Alice with `nonce_changes` (tx nonce + auth nonce increment) but **MUST NOT** include `code_changes` (setting `b"" -> b""` is net-zero and filtered out). Bob: `balance_changes` (receives 10 wei). This ensures net-zero code change is not recorded. | `test_bal_sstore_and_oog` | Ensure BAL handles OOG during SSTORE execution at various gas boundaries (EIP-2200 stipend and implicit SLOAD) | Alice calls contract that attempts `SSTORE` to cold slot `0x01`. Parameterized: (1) OOG at EIP-2200 stipend check (2300 gas after PUSH opcodes) - fails before implicit SLOAD, (2) OOG at stipend + 1 (2301 gas) - passes stipend check but fails after implicit SLOAD, (3) OOG at exact gas - 1, (4) Successful SSTORE with exact gas. | For case (1): BAL **MUST NOT** include slot `0x01` in `storage_reads` or `storage_changes` (fails before implicit SLOAD). For cases (2) and (3): BAL **MUST** include slot `0x01` in `storage_reads` (implicit SLOAD occurred) but **MUST NOT** include in `storage_changes` (write didn't complete). For case (4): BAL **MUST** include slot `0x01` in `storage_changes` only (successful write; read is filtered by builder). | ✅ Completed | | `test_bal_sload_and_oog` | Ensure BAL handles OOG during SLOAD execution correctly | Alice calls contract that attempts `SLOAD` from cold slot `0x01`. Parameterized: (1) OOG at SLOAD opcode (insufficient gas), (2) Successful SLOAD execution. | For OOG case: BAL **MUST NOT** contain slot `0x01` in `storage_reads` since storage wasn't accessed. For success case: BAL **MUST** contain slot `0x01` in `storage_reads`. | ✅ Completed | | `test_bal_balance_and_oog` | Ensure BAL handles OOG during BALANCE opcode execution correctly | Alice calls contract that attempts `BALANCE` opcode on cold target account. Parameterized: (1) OOG at BALANCE opcode (insufficient gas), (2) Successful BALANCE execution. | For OOG case: BAL **MUST NOT** include target account (wasn't accessed). For success case: BAL **MUST** include target account in `account_changes`. | ✅ Completed | From bc46e65144c05df829038552c75d6742643d2304 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 12 Nov 2025 13:57:12 -0700 Subject: [PATCH 17/51] fix(spec-specs): Use child frame for create message --- .../forks/amsterdam/vm/interpreter.py | 15 +++- .../test_block_access_lists_opcodes.py | 83 +++++++++++++++++++ .../test_cases.md | 1 + 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index ab07912389..9d6190b0e8 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -252,9 +252,11 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - increment_nonce( - state, message.current_target, message.block_env.block_state_changes - ) + # Create a temporary child frame for tracking changes that may be rolled + # back on OOG during code deposit. This frame is merged only on success. + create_frame = create_child_frame(message.block_env.block_state_changes) + + increment_nonce(state, message.current_target, create_frame) evm = process_message(message) if not evm.error: contract_code = evm.output @@ -268,6 +270,9 @@ def process_create_message(message: Message) -> Evm: raise OutOfGasError except ExceptionalHalt as error: rollback_transaction(state, transient_storage) + # Merge create_frame on failure - keeps reads, discards writes + # (address access is preserved, nonce change is discarded) + create_frame.merge_on_failure() evm.gas_left = Uint(0) evm.output = b"" evm.error = error @@ -276,9 +281,11 @@ def process_create_message(message: Message) -> Evm: state, message.current_target, contract_code, - message.block_env.block_state_changes, + create_frame, ) commit_transaction(state, transient_storage) + # Merge create_frame on success - includes nonce and code changes + create_frame.merge_on_success() else: rollback_transaction(state, transient_storage) return evm diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 17799d3655..207a87d9a8 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -32,6 +32,7 @@ Fork, Op, Transaction, + compute_create_address, ) from .spec import ref_spec_7928 @@ -754,3 +755,85 @@ def test_bal_storage_write_read_cross_frame( oracle: Account(storage={0x01: 0x42}), }, ) + + +def test_bal_create_oog_code_deposit( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, +) -> None: + """ + Ensure BAL correctly handles CREATE that runs out of gas during code + deposit. The contract address should appear with empty changes (read + during collision check) but no nonce or code changes (rolled back). + """ + alice = pre.fund_eoa() + + # create init code that returns a very large contract to force OOG + deposited_len = 10_000 + initcode = Op.RETURN(0, deposited_len) + + factory = pre.deploy_contract( + code=Op.MSTORE(0, Op.PUSH32(bytes(initcode))) + + Op.SSTORE( + 1, Op.CREATE(offset=32 - len(initcode), size=len(initcode)) + ) + + Op.STOP, + storage={1: 0xDEADBEEF}, + ) + + contract_address = compute_create_address(address=factory, nonce=1) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas = intrinsic_gas_calculator( + calldata=b"", + contract_creation=False, + access_list=[], + ) + + tx = Transaction( + sender=alice, + to=factory, + gas_limit=intrinsic_gas + 500_000, # insufficient for deposit + ) + + # BAL expectations: + # - Alice: nonce change (tx sender) + # - Factory: nonce change (CREATE increments factory nonce) + # - Contract address: empty changes (read during collision check, + # nonce/code changes rolled back on OOG) + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + factory: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + storage_changes=[ + BalStorageSlot( + slot=1, + slot_changes=[ + # SSTORE saves 0 (CREATE failed) + BalStorageChange(tx_index=1, post_value=0), + ], + ) + ], + ), + contract_address: BalAccountExpectation.empty(), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=1), + factory: Account(nonce=2, storage={1: 0}), + contract_address: Account.NONEXISTENT, + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 34fa49528d..ceea2b0842 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -49,6 +49,7 @@ | `test_bal_extcodecopy_and_oog` | Ensure BAL handles OOG during EXTCODECOPY opcode execution correctly | Alice calls contract that attempts `EXTCODECOPY` from cold target contract. Parameterized: (1) OOG at EXTCODECOPY opcode (insufficient gas), (2) Successful EXTCODECOPY execution. | For OOG case: BAL **MUST NOT** include target contract (wasn't accessed). For success case: BAL **MUST** include target contract in `account_changes`. | ✅ Completed | | `test_bal_oog_7702_delegated_cold_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when both accounts are cold | Alice calls cold delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (first cold load succeeds) but **MUST NOT** include `TargetContract` (second cold load fails due to OOG) | 🟡 Planned | | `test_bal_oog_7702_delegated_warm_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when first account is warm, second is cold | Alice calls warm delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (warm load succeeds) but **MUST NOT** include `TargetContract` (cold load fails due to OOG) | 🟡 Planned | +| `test_bal_create_oog_code_deposit` | Ensure BAL correctly handles CREATE OOG during code deposit | Alice calls factory contract that executes CREATE with init code returning 10,000 bytes. Transaction has insufficient gas for code deposit. Factory nonce increments, CREATE returns 0 and stores in slot 1. | BAL **MUST** include Alice with `nonce_changes`. Factory with `nonce_changes` (incremented by CREATE) and `storage_changes` (slot 1 = 0). Contract address with empty changes (read during collision check). **MUST NOT** include nonce or code changes for contract address (rolled back on OOG). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_invalid_missing_nonce` | Verify clients reject blocks with BAL missing required nonce changes | Alice sends transaction to Bob; BAL modifier removes Alice's nonce change entry | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that all sender accounts have nonce changes recorded. | ✅ Completed | | `test_bal_invalid_nonce_value` | Verify clients reject blocks with incorrect nonce values in BAL | Alice sends transaction to Bob; BAL modifier changes Alice's nonce to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate nonce values match actual state transitions. | ✅ Completed | | `test_bal_invalid_storage_value` | Verify clients reject blocks with incorrect storage values in BAL | Alice calls contract that writes to storage; BAL modifier changes storage value to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate storage change values match actual state transitions. | ✅ Completed | From 40e7accc80dfa8e2240feb79561cc6de0782c204 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 12 Nov 2025 15:07:17 -0700 Subject: [PATCH 18/51] fix(spec-specs): Normalize transaction before merging to block frame --- src/ethereum/forks/amsterdam/fork.py | 20 ++--- .../test_block_access_lists.py | 89 +++++++++++++++++++ .../test_cases.md | 1 + 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 86a7089a30..25968e4598 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1051,13 +1051,20 @@ def process_transaction( block_output.block_logs += tx_output.logs + # EIP-7928: Normalize balance changes for this transaction before merging + # into block frame. + normalize_balance_changes_for_transaction( + tx_state_changes, + BlockAccessIndex(tx_state_changes.get_block_access_index()), + block_env.state, + ) + # Merge transaction frame into block frame tx_state_changes.merge_on_success() # EIP-7928: Handle in-transaction self-destruct AFTER merge # Convert storage writes to reads and remove nonce/code changes # Only accounts created in same tx are in accounts_to_delete per EIP-6780 - for address in tx_output.accounts_to_delete: handle_in_transaction_selfdestruct( block_env.block_state_changes, @@ -1068,17 +1075,6 @@ def process_transaction( ) destroy_account(block_env.state, address) - # EIP-7928: Normalize balance changes for this transaction - # Remove balance changes where post-tx balance equals pre-tx balance - - normalize_balance_changes_for_transaction( - block_env.block_state_changes, - BlockAccessIndex( - block_env.block_state_changes.get_block_access_index() - ), - block_env.state, - ) - def process_withdrawals( block_env: vm.BlockEnvironment, diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 9f1214fe4c..6d03e9bb9a 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -1925,3 +1925,92 @@ def test_bal_nonexistent_account_access_value_transfer( else Account.NONEXISTENT, }, ) + + +def test_bal_multiple_balance_changes_same_account( + pre: Alloc, + fork: Fork, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL correctly tracks multiple balance changes to same account + across multiple transactions. + + An account that receives funds in TX0 and spends them in TX1 should + have TWO balance change entries in the BAL, one for each transaction. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + charlie = pre.fund_eoa(amount=0) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + tx_intrinsic_gas = intrinsic_gas_calculator(calldata=b"", access_list=[]) + + # bob receives funds in tx0, then spends everything in tx1 + gas_price = 10 + tx1_gas_cost = tx_intrinsic_gas * gas_price + spend_amount = 100 + funding_amount = tx1_gas_cost + spend_amount + + tx0 = Transaction( + sender=alice, + to=bob, + value=funding_amount, + gas_limit=tx_intrinsic_gas, + gas_price=gas_price, + ) + + tx1 = Transaction( + sender=bob, + to=charlie, + value=spend_amount, + gas_limit=tx_intrinsic_gas, + gas_price=gas_price, + ) + + bob_balance_after_tx0 = funding_amount + bob_balance_after_tx1 = 0 + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx0, tx1], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=1, post_nonce=1) + ], + ), + bob: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=2, post_nonce=1) + ], + balance_changes=[ + BalBalanceChange( + tx_index=1, + post_balance=bob_balance_after_tx0, + ), + BalBalanceChange( + tx_index=2, + post_balance=bob_balance_after_tx1, + ), + ], + ), + charlie: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=2, post_balance=spend_amount + ) + ], + ), + } + ), + ) + ], + post={ + bob: Account(nonce=1, balance=bob_balance_after_tx1), + charlie: Account(balance=spend_amount), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index ceea2b0842..c33f56eb43 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -49,6 +49,7 @@ | `test_bal_extcodecopy_and_oog` | Ensure BAL handles OOG during EXTCODECOPY opcode execution correctly | Alice calls contract that attempts `EXTCODECOPY` from cold target contract. Parameterized: (1) OOG at EXTCODECOPY opcode (insufficient gas), (2) Successful EXTCODECOPY execution. | For OOG case: BAL **MUST NOT** include target contract (wasn't accessed). For success case: BAL **MUST** include target contract in `account_changes`. | ✅ Completed | | `test_bal_oog_7702_delegated_cold_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when both accounts are cold | Alice calls cold delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (first cold load succeeds) but **MUST NOT** include `TargetContract` (second cold load fails due to OOG) | 🟡 Planned | | `test_bal_oog_7702_delegated_warm_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when first account is warm, second is cold | Alice calls warm delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (warm load succeeds) but **MUST NOT** include `TargetContract` (cold load fails due to OOG) | 🟡 Planned | +| `test_bal_multiple_balance_changes_same_account` | Ensure BAL tracks multiple balance changes to same account across transactions | Alice funds Bob (starts at 0) in tx0 with exact amount needed. Bob spends everything in tx1 to Charlie. Bob's balance: 0 → funding_amount → 0 | BAL **MUST** include Bob with two `balance_changes`: one at txIndex=1 (receives funds) and one at txIndex=2 (balance returns to 0). This tests balance tracking across two transactions. | ✅ Completed | | `test_bal_create_oog_code_deposit` | Ensure BAL correctly handles CREATE OOG during code deposit | Alice calls factory contract that executes CREATE with init code returning 10,000 bytes. Transaction has insufficient gas for code deposit. Factory nonce increments, CREATE returns 0 and stores in slot 1. | BAL **MUST** include Alice with `nonce_changes`. Factory with `nonce_changes` (incremented by CREATE) and `storage_changes` (slot 1 = 0). Contract address with empty changes (read during collision check). **MUST NOT** include nonce or code changes for contract address (rolled back on OOG). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_invalid_missing_nonce` | Verify clients reject blocks with BAL missing required nonce changes | Alice sends transaction to Bob; BAL modifier removes Alice's nonce change entry | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that all sender accounts have nonce changes recorded. | ✅ Completed | | `test_bal_invalid_nonce_value` | Verify clients reject blocks with incorrect nonce values in BAL | Alice sends transaction to Bob; BAL modifier changes Alice's nonce to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate nonce values match actual state transitions. | ✅ Completed | From c9851e19c45eeb7065132b5f39fa481c5124d704 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 12 Nov 2025 15:43:40 -0700 Subject: [PATCH 19/51] fix(spec-specs): Early static check for SSTORE before any reads --- .../amsterdam/vm/instructions/storage.py | 6 +- .../test_block_access_lists_opcodes.py | 73 +++++++++++++++++++ .../test_cases.md | 1 + 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index 1709dab5d7..db1536a707 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -90,6 +90,10 @@ def sstore(evm: Evm) -> None: if evm.gas_left <= GAS_CALL_STIPEND: raise OutOfGasError + # Check static context before accessing storage + if evm.message.is_static: + raise WriteInStaticContext + state = evm.message.block_env.state original_value = get_storage_original( state, evm.message.current_target, key @@ -129,8 +133,6 @@ def sstore(evm: Evm) -> None: evm.accessed_storage_keys.add((evm.message.current_target, key)) charge_gas(evm, gas_cost) - if evm.message.is_static: - raise WriteInStaticContext # REFUND COUNTER if current_value != new_value: diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 207a87d9a8..de354bbeb1 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -837,3 +837,76 @@ def test_bal_create_oog_code_deposit( contract_address: Account.NONEXISTENT, }, ) + + +def test_bal_sstore_static_context( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL does not record storage reads when SSTORE fails in static + context. + + Contract A makes STATICCALL to Contract B. Contract B attempts SSTORE, + which should fail immediately without recording any storage reads. + """ + alice = pre.fund_eoa() + + contract_b = pre.deploy_contract(code=Op.SSTORE(0, 5)) + + # Contract A makes STATICCALL to Contract B + # The STATICCALL will fail because B tries SSTORE in static context + # But contract_a continues and writes to its own storage + contract_a = pre.deploy_contract( + code=Op.STATICCALL( + gas=1_000_000, + address=contract_b, + args_offset=0, + args_size=0, + ret_offset=0, + ret_size=0, + ) + + Op.POP # pop the return value (0 = failure) + + Op.SSTORE(0, 1) # this should succeed (non-static context) + ) + + tx = Transaction( + sender=alice, + to=contract_a, + gas_limit=2_000_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=1, post_nonce=1) + ], + ), + contract_a: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange( + tx_index=1, post_value=1 + ), + ], + ), + ], + ), + contract_b: BalAccountExpectation.empty(), + } + ), + ) + ], + post={ + contract_a: Account(storage={0: 1}), + contract_b: Account(storage={0: 0}), # SSTORE failed + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index c33f56eb43..1f329b9a3b 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -41,6 +41,7 @@ | `test_bal_7702_delegated_via_call_opcode` | Ensure BAL captures delegation target when a contract uses *CALL opcodes to call a delegated account | Pre-deployed contract `Alice` delegated to `Oracle`. `Caller` contract uses CALL/CALLCODE/DELEGATECALL/STATICCALL to call `Alice`. Bob sends transaction to `Caller`. | BAL **MUST** include Bob: `nonce_changes`. `Caller`: empty changes (account access). `Alice`: empty changes (account access - delegated account being called). `Oracle`: empty changes (delegation target access). | ✅ Completed | | `test_bal_7702_null_address_delegation` | Ensure BAL does not record spurious code changes for net-zero code operations | Alice sends transaction with authorization delegating to NULL_ADDRESS (0x0), which sets code to `b""` on an account that already has `b""` code. Transaction sends 10 wei to Bob. | BAL **MUST** include Alice with `nonce_changes` (tx nonce + auth nonce increment) but **MUST NOT** include `code_changes` (setting `b"" -> b""` is net-zero and filtered out). Bob: `balance_changes` (receives 10 wei). This ensures net-zero code change is not recorded. | `test_bal_sstore_and_oog` | Ensure BAL handles OOG during SSTORE execution at various gas boundaries (EIP-2200 stipend and implicit SLOAD) | Alice calls contract that attempts `SSTORE` to cold slot `0x01`. Parameterized: (1) OOG at EIP-2200 stipend check (2300 gas after PUSH opcodes) - fails before implicit SLOAD, (2) OOG at stipend + 1 (2301 gas) - passes stipend check but fails after implicit SLOAD, (3) OOG at exact gas - 1, (4) Successful SSTORE with exact gas. | For case (1): BAL **MUST NOT** include slot `0x01` in `storage_reads` or `storage_changes` (fails before implicit SLOAD). For cases (2) and (3): BAL **MUST** include slot `0x01` in `storage_reads` (implicit SLOAD occurred) but **MUST NOT** include in `storage_changes` (write didn't complete). For case (4): BAL **MUST** include slot `0x01` in `storage_changes` only (successful write; read is filtered by builder). | ✅ Completed | +| `test_bal_sstore_static_context` | Ensure BAL does not capture spurious storage access when SSTORE fails in static context | Alice calls contract with `STATICCALL` which attempts `SSTORE` to slot `0x01`. SSTORE must fail before any storage access occurs. | BAL **MUST NOT** include slot `0x01` in `storage_reads` or `storage_changes`. Static context check happens before storage access, preventing spurious reads. Alice has `nonce_changes` and `balance_changes` (gas cost). Target contract included with empty changes. | ✅ Completed | | `test_bal_sload_and_oog` | Ensure BAL handles OOG during SLOAD execution correctly | Alice calls contract that attempts `SLOAD` from cold slot `0x01`. Parameterized: (1) OOG at SLOAD opcode (insufficient gas), (2) Successful SLOAD execution. | For OOG case: BAL **MUST NOT** contain slot `0x01` in `storage_reads` since storage wasn't accessed. For success case: BAL **MUST** contain slot `0x01` in `storage_reads`. | ✅ Completed | | `test_bal_balance_and_oog` | Ensure BAL handles OOG during BALANCE opcode execution correctly | Alice calls contract that attempts `BALANCE` opcode on cold target account. Parameterized: (1) OOG at BALANCE opcode (insufficient gas), (2) Successful BALANCE execution. | For OOG case: BAL **MUST NOT** include target account (wasn't accessed). For success case: BAL **MUST** include target account in `account_changes`. | ✅ Completed | | `test_bal_extcodesize_and_oog` | Ensure BAL handles OOG during EXTCODESIZE opcode execution correctly | Alice calls contract that attempts `EXTCODESIZE` opcode on cold target contract. Parameterized: (1) OOG at EXTCODESIZE opcode (insufficient gas), (2) Successful EXTCODESIZE execution. | For OOG case: BAL **MUST NOT** include target contract (wasn't accessed). For success case: BAL **MUST** include target contract in `account_changes`. | ✅ Completed | From 1d9bf1fcc3c3fb4bcbddbb990f14ff6dcf87a279 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 12 Nov 2025 18:50:27 -0700 Subject: [PATCH 20/51] fix(spec-specs): Track storage writes more appropriately wrt index --- .../amsterdam/block_access_lists/builder.py | 7 +- src/ethereum/forks/amsterdam/state_tracker.py | 25 +++---- .../test_block_access_lists.py | 74 +++++++++++++++++++ .../test_cases.md | 1 + 4 files changed, 90 insertions(+), 17 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index 3e1870b0b5..4ed7aa767c 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -484,10 +484,11 @@ def build_block_access_list( add_storage_read(builder, address, slot) # Add all storage writes, filtering net-zero changes - for (address, slot), ( + for ( + address, + slot, block_access_index, - value, - ) in state_changes.storage_writes.items(): + ), value in state_changes.storage_writes.items(): # Check if this is a net-zero change by comparing with pre-state if (address, slot) in state_changes.pre_storage: if state_changes.pre_storage[(address, slot)] == value: diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 42203d5e8d..0d875be3c0 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -46,9 +46,9 @@ class StateChanges: touched_addresses: Set[Address] = field(default_factory=set) storage_reads: Set[Tuple[Address, Bytes32]] = field(default_factory=set) - storage_writes: Dict[ - Tuple[Address, Bytes32], Tuple[BlockAccessIndex, U256] - ] = field(default_factory=dict) + storage_writes: Dict[Tuple[Address, Bytes32, BlockAccessIndex], U256] = ( + field(default_factory=dict) + ) balance_changes: Dict[Tuple[Address, BlockAccessIndex], U256] = field( default_factory=dict @@ -110,9 +110,8 @@ def track_storage_write( self, address: Address, key: Bytes32, value: U256 ) -> None: """Track a storage write operation with block access index.""" - self.storage_writes[(address, key)] = ( - self.get_block_access_index(), - value, + self.storage_writes[(address, key, self.get_block_access_index())] = ( + value ) def track_balance_change( @@ -173,17 +172,17 @@ def merge_on_success(self) -> None: # Merge storage operations, filtering noop writes self.parent.storage_reads.update(self.storage_reads) - for (addr, key), (idx, value) in self.storage_writes.items(): + for (addr, key, idx), value in self.storage_writes.items(): # Only merge if value actually changed from pre-state if (addr, key) in self.pre_storage: if self.pre_storage[(addr, key)] != value: - self.parent.storage_writes[(addr, key)] = (idx, value) + self.parent.storage_writes[(addr, key, idx)] = value # If equal, it's a noop write - convert to read only else: self.parent.storage_reads.add((addr, key)) else: # No pre-state captured, merge as-is - self.parent.storage_writes[(addr, key)] = (idx, value) + self.parent.storage_writes[(addr, key, idx)] = value # Merge balance changes - filter net-zero changes # balance_changes keyed by (address, index) @@ -240,7 +239,7 @@ def merge_on_failure(self) -> None: self.parent.storage_reads.update(self.storage_reads) # Convert writes to reads (failed writes still accessed the slots) - for address, key in self.storage_writes.keys(): + for address, key, _idx in self.storage_writes.keys(): self.parent.storage_reads.add((address, key)) # Note: balance_changes, nonce_changes, and code_changes are NOT @@ -284,11 +283,9 @@ def handle_in_transaction_selfdestruct( del state_changes.code_changes[(address, current_block_access_index)] # Convert storage writes from current transaction to reads - for (addr, key), (idx, _value) in list( - state_changes.storage_writes.items() - ): + for addr, key, idx in list(state_changes.storage_writes.keys()): if addr == address and idx == current_block_access_index: - del state_changes.storage_writes[(addr, key)] + del state_changes.storage_writes[(addr, key, idx)] state_changes.storage_reads.add((addr, key)) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 6d03e9bb9a..baead4938e 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -2014,3 +2014,77 @@ def test_bal_multiple_balance_changes_same_account( charlie: Account(balance=spend_amount), }, ) + + +def test_bal_multiple_storage_writes_same_slot( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Test that BAL tracks multiple writes to the same storage slot across + transactions in the same block. + + Setup: + - Deploy a contract that increments storage slot 1 on each call + - Alice calls the contract 3 times in the same block + - Each call increments slot 1: 0 -> 1 -> 2 -> 3 + + Expected BAL: + - Contract should have 3 storage_changes for slot 1: + * txIndex 1: postValue = 1 + * txIndex 2: postValue = 2 + * txIndex 3: postValue = 3 + """ + alice = pre.fund_eoa(amount=10**18) + + increment_code = Op.SSTORE(1, Op.ADD(Op.SLOAD(1), 1)) + contract = pre.deploy_contract(code=increment_code) + + tx1 = Transaction(sender=alice, to=contract, gas_limit=200_000) + tx2 = Transaction(sender=alice, to=contract, gas_limit=200_000) + tx3 = Transaction(sender=alice, to=contract, gas_limit=200_000) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1, tx2, tx3], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=1, post_nonce=1), + BalNonceChange(tx_index=2, post_nonce=2), + BalNonceChange(tx_index=3, post_nonce=3), + ], + ), + contract: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=1, + slot_changes=[ + BalStorageChange( + tx_index=1, post_value=1 + ), + BalStorageChange( + tx_index=2, post_value=2 + ), + BalStorageChange( + tx_index=3, post_value=3 + ), + ], + ), + ], + storage_reads=[], + balance_changes=[], + code_changes=[], + ), + } + ), + ) + ], + post={ + alice: Account(nonce=3), + contract: Account(storage={1: 3}), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 1f329b9a3b..bc45ef2d21 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -51,6 +51,7 @@ | `test_bal_oog_7702_delegated_cold_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when both accounts are cold | Alice calls cold delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (first cold load succeeds) but **MUST NOT** include `TargetContract` (second cold load fails due to OOG) | 🟡 Planned | | `test_bal_oog_7702_delegated_warm_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when first account is warm, second is cold | Alice calls warm delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (warm load succeeds) but **MUST NOT** include `TargetContract` (cold load fails due to OOG) | 🟡 Planned | | `test_bal_multiple_balance_changes_same_account` | Ensure BAL tracks multiple balance changes to same account across transactions | Alice funds Bob (starts at 0) in tx0 with exact amount needed. Bob spends everything in tx1 to Charlie. Bob's balance: 0 → funding_amount → 0 | BAL **MUST** include Bob with two `balance_changes`: one at txIndex=1 (receives funds) and one at txIndex=2 (balance returns to 0). This tests balance tracking across two transactions. | ✅ Completed | +| `test_bal_multiple_storage_writes_same_slot` | Ensure BAL tracks multiple writes to same storage slot across transactions | Alice calls contract 3 times in same block. Contract increments slot 1 on each call: 0 → 1 → 2 → 3 | BAL **MUST** include contract with slot 1 having three `slot_changes`: txIndex=1 (value 1), txIndex=2 (value 2), txIndex=3 (value 3). Each transaction's write must be recorded separately. | ✅ Completed | | `test_bal_create_oog_code_deposit` | Ensure BAL correctly handles CREATE OOG during code deposit | Alice calls factory contract that executes CREATE with init code returning 10,000 bytes. Transaction has insufficient gas for code deposit. Factory nonce increments, CREATE returns 0 and stores in slot 1. | BAL **MUST** include Alice with `nonce_changes`. Factory with `nonce_changes` (incremented by CREATE) and `storage_changes` (slot 1 = 0). Contract address with empty changes (read during collision check). **MUST NOT** include nonce or code changes for contract address (rolled back on OOG). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_invalid_missing_nonce` | Verify clients reject blocks with BAL missing required nonce changes | Alice sends transaction to Bob; BAL modifier removes Alice's nonce change entry | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that all sender accounts have nonce changes recorded. | ✅ Completed | | `test_bal_invalid_nonce_value` | Verify clients reject blocks with incorrect nonce values in BAL | Alice sends transaction to Bob; BAL modifier changes Alice's nonce to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate nonce values match actual state transitions. | ✅ Completed | From 42def645adb567ab4067b4ebfe6692c2fd283a41 Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 13 Nov 2025 15:09:39 -0600 Subject: [PATCH 21/51] fix(spec-specs): Use functions, not methods; fix create revert --- src/ethereum/forks/amsterdam/fork.py | 46 +- src/ethereum/forks/amsterdam/state.py | 36 +- src/ethereum/forks/amsterdam/state_tracker.py | 593 ++++++++++++------ src/ethereum/forks/amsterdam/vm/__init__.py | 9 +- .../forks/amsterdam/vm/eoa_delegation.py | 11 +- .../amsterdam/vm/instructions/environment.py | 9 +- .../amsterdam/vm/instructions/storage.py | 21 +- .../forks/amsterdam/vm/instructions/system.py | 36 +- .../forks/amsterdam/vm/interpreter.py | 90 +-- .../evm_tools/t8n/__init__.py | 6 +- .../test_block_access_lists_opcodes.py | 59 ++ .../test_cases.md | 1 + 12 files changed, 597 insertions(+), 320 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 25968e4598..dcc46d1c4f 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -65,9 +65,15 @@ state_root, ) from .state_tracker import ( + capture_pre_balance, create_child_frame, + get_block_access_index, handle_in_transaction_selfdestruct, + increment_block_access_index, + merge_on_success, normalize_balance_changes_for_transaction, + track_address, + track_balance_change, ) from .transactions import ( AccessListTransaction, @@ -800,7 +806,7 @@ def apply_body( # EIP-7928: Increment block frame to post-execution index # After N transactions, block frame is at index N # Post-execution operations (withdrawals, etc.) use index N+1 - block_env.block_state_changes.increment_index() + increment_block_access_index(block_env.block_state_changes) process_withdrawals(block_env, block_output, withdrawals) @@ -893,16 +899,15 @@ def process_transaction( """ # EIP-7928: Create a transaction-level StateChanges frame # The frame will read the current block_access_index from the block frame - # Before transaction starts, increment block index so it's ready - block_env.block_state_changes.increment_index() + increment_block_access_index(block_env.block_state_changes) tx_state_changes = create_child_frame(block_env.block_state_changes) coinbase_pre_balance = get_account( block_env.state, block_env.coinbase ).balance - tx_state_changes.track_address(block_env.coinbase) - tx_state_changes.capture_pre_balance( - block_env.coinbase, coinbase_pre_balance + track_address(tx_state_changes, block_env.coinbase) + capture_pre_balance( + tx_state_changes, block_env.coinbase, coinbase_pre_balance ) trie_set( @@ -1012,15 +1017,16 @@ def process_transaction( block_env.state, sender ).balance + U256(gas_refund_amount) set_account_balance( - block_env.state, sender, sender_balance_after_refund, tx_state_changes + block_env.state, + sender, + sender_balance_after_refund, + tx_state_changes, ) - # transfer miner fees coinbase_balance_after_mining_fee = get_account( block_env.state, block_env.coinbase ).balance + U256(transaction_fee) - # Always set coinbase balance to ensure proper tracking set_account_balance( block_env.state, block_env.coinbase, @@ -1055,12 +1061,13 @@ def process_transaction( # into block frame. normalize_balance_changes_for_transaction( tx_state_changes, - BlockAccessIndex(tx_state_changes.get_block_access_index()), + BlockAccessIndex( + get_block_access_index(block_env.block_state_changes) + ), block_env.state, ) - # Merge transaction frame into block frame - tx_state_changes.merge_on_success() + merge_on_success(tx_state_changes) # EIP-7928: Handle in-transaction self-destruct AFTER merge # Convert storage writes to reads and remove nonce/code changes @@ -1070,7 +1077,7 @@ def process_transaction( block_env.block_state_changes, address, BlockAccessIndex( - block_env.block_state_changes.get_block_access_index() + get_block_access_index(block_env.block_state_changes) ), ) destroy_account(block_env.state, address) @@ -1087,8 +1094,10 @@ def process_withdrawals( withdrawal_addresses = {wd.address for wd in withdrawals} for address in withdrawal_addresses: pre_balance = get_account(block_env.state, address).balance - block_env.block_state_changes.track_address(address) - block_env.block_state_changes.capture_pre_balance(address, pre_balance) + track_address(block_env.block_state_changes, address) + capture_pre_balance( + block_env.block_state_changes, address, pre_balance + ) def increase_recipient_balance(recipient: Account) -> None: recipient.balance += wd.amount * U256(10**9) @@ -1102,10 +1111,9 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(block_env.state, wd.address, increase_recipient_balance) - # Track balance change for BAL (withdrawals use post-execution index) new_balance = get_account(block_env.state, wd.address).balance - block_env.block_state_changes.track_balance_change( - wd.address, new_balance + track_balance_change( + block_env.block_state_changes, wd.address, new_balance ) if account_exists_and_is_empty(block_env.state, wd.address): @@ -1117,7 +1125,7 @@ def increase_recipient_balance(recipient: Account) -> None: normalize_balance_changes_for_transaction( block_env.block_state_changes, BlockAccessIndex( - block_env.block_state_changes.get_block_access_index() + get_block_access_index(block_env.block_state_changes) ), block_env.state, ) diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index 8f58b6e815..af384ec4df 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -24,7 +24,14 @@ from ethereum_types.numeric import U64, U256, Uint from .fork_types import EMPTY_ACCOUNT, Account, Address, Root -from .state_tracker import StateChanges +from .state_tracker import ( + StateChanges, + capture_pre_balance, + track_address, + track_balance_change, + track_code_change, + track_nonce_change, +) from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set if TYPE_CHECKING: @@ -518,10 +525,10 @@ def move_ether( sender_balance = get_account(state, sender_address).balance recipient_balance = get_account(state, recipient_address).balance - state_changes.track_address(sender_address) - state_changes.capture_pre_balance(sender_address, sender_balance) - state_changes.track_address(recipient_address) - state_changes.capture_pre_balance(recipient_address, recipient_balance) + track_address(state_changes, sender_address) + capture_pre_balance(state_changes, sender_address, sender_balance) + track_address(state_changes, recipient_address) + capture_pre_balance(state_changes, recipient_address, recipient_balance) def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -537,11 +544,11 @@ def increase_recipient_balance(recipient: Account) -> None: sender_new_balance = get_account(state, sender_address).balance recipient_new_balance = get_account(state, recipient_address).balance - state_changes.track_balance_change( - sender_address, U256(sender_new_balance) + track_balance_change( + state_changes, sender_address, U256(sender_new_balance) ) - state_changes.track_balance_change( - recipient_address, U256(recipient_new_balance) + track_balance_change( + state_changes, recipient_address, U256(recipient_new_balance) ) @@ -571,14 +578,14 @@ def set_account_balance( """ current_balance = get_account(state, address).balance - state_changes.track_address(address) - state_changes.capture_pre_balance(address, current_balance) + track_address(state_changes, address) + capture_pre_balance(state_changes, address, current_balance) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) - state_changes.track_balance_change(address, amount) + track_balance_change(state_changes, address, amount) def increment_nonce( @@ -607,9 +614,8 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) - # Track nonce change for Block Access List (EIP-7928) account = get_account(state, address) - state_changes.track_nonce_change(address, U64(account.nonce)) + track_nonce_change(state_changes, address, U64(account.nonce)) def set_code( @@ -641,7 +647,7 @@ def write_code(sender: Account) -> None: sender.code = code modify_state(state, address, write_code) - state_changes.track_code_change(address, code) + track_code_change(state_changes, address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 0d875be3c0..2e9385d272 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -12,7 +12,7 @@ Call Frame: Child of transaction/call, lifetime = single message Block Access Index: 0=pre-exec, 1..N=transactions, N+1=post-exec -Stored in root frame, accessed by walking parent chain. +Stored in root frame, passed explicitly to operations. Pre-State Tracking: Values captured before modifications to enable net-zero filtering. @@ -38,7 +38,8 @@ class StateChanges: """ Tracks state changes within a single execution frame. - Frames form a hierarchy and merge changes upward on completion. + Frames form a hierarchy: Block → Transaction → Call frames. + Each frame holds a reference to its parent for upward traversal. """ parent: Optional["StateChanges"] = None @@ -68,182 +69,409 @@ class StateChanges: ) pre_code: Dict[Address, Bytes] = field(default_factory=dict) - def get_block_access_index(self) -> BlockAccessIndex: - """Get current block access index by walking to root.""" - current = self - while current.parent is not None: - current = current.parent - return current._block_access_index - - def capture_pre_balance(self, address: Address, balance: U256) -> None: - """Capture pre-balance (first-write-wins for net-zero filtering).""" - if address not in self.pre_balances: - self.pre_balances[address] = balance - - def capture_pre_nonce(self, address: Address, nonce: U64) -> None: - """Capture pre-nonce (first-write-wins).""" - if address not in self.pre_nonces: - self.pre_nonces[address] = nonce - - def capture_pre_storage( - self, address: Address, key: Bytes32, value: U256 - ) -> None: - """Capture pre-storage (first-write-wins for noop filtering).""" - slot = (address, key) - if slot not in self.pre_storage: - self.pre_storage[slot] = value - - def capture_pre_code(self, address: Address, code: Bytes) -> None: - """Capture pre-code (first-write-wins).""" - if address not in self.pre_code: - self.pre_code[address] = code - - def track_address(self, address: Address) -> None: - """Track that an address was accessed.""" - self.touched_addresses.add(address) - - def track_storage_read(self, address: Address, key: Bytes32) -> None: - """Track a storage read operation.""" - self.storage_reads.add((address, key)) - - def track_storage_write( - self, address: Address, key: Bytes32, value: U256 - ) -> None: - """Track a storage write operation with block access index.""" - self.storage_writes[(address, key, self.get_block_access_index())] = ( - value - ) - - def track_balance_change( - self, address: Address, new_balance: U256 - ) -> None: - """Track balance change keyed by (address, index).""" - self.balance_changes[(address, self.get_block_access_index())] = ( - new_balance - ) - - def track_nonce_change(self, address: Address, new_nonce: U64) -> None: - """Track a nonce change.""" - self.nonce_changes.add( - (address, self.get_block_access_index(), new_nonce) - ) - - def track_code_change(self, address: Address, new_code: Bytes) -> None: - """Track a code change.""" - self.code_changes[(address, self.get_block_access_index())] = new_code - - def increment_index(self) -> None: - """Increment block access index by walking to root.""" - root = self - while root.parent is not None: - root = root.parent - root._block_access_index = BlockAccessIndex( - root._block_access_index + Uint(1) - ) - - def merge_on_success(self) -> None: - """ - Merge this frame's changes into parent on successful completion. - - Merges all tracked changes (reads and writes) from this frame - into the parent frame. Filters out net-zero changes based on - captured pre-state values by comparing initial vs final values. - """ - if self.parent is None: - return - - # Merge address accesses - self.parent.touched_addresses.update(self.touched_addresses) - - # Merge pre-state captures for transaction-level normalization - # Only if parent doesn't have value (first capture wins) - for addr, balance in self.pre_balances.items(): - if addr not in self.parent.pre_balances: - self.parent.pre_balances[addr] = balance - for addr, nonce in self.pre_nonces.items(): - if addr not in self.parent.pre_nonces: - self.parent.pre_nonces[addr] = nonce - for slot, value in self.pre_storage.items(): - if slot not in self.parent.pre_storage: - self.parent.pre_storage[slot] = value - for addr, code in self.pre_code.items(): - if addr not in self.parent.pre_code: - self.parent.pre_code[addr] = code - - # Merge storage operations, filtering noop writes - self.parent.storage_reads.update(self.storage_reads) - for (addr, key, idx), value in self.storage_writes.items(): - # Only merge if value actually changed from pre-state - if (addr, key) in self.pre_storage: - if self.pre_storage[(addr, key)] != value: - self.parent.storage_writes[(addr, key, idx)] = value - # If equal, it's a noop write - convert to read only - else: - self.parent.storage_reads.add((addr, key)) - else: - # No pre-state captured, merge as-is - self.parent.storage_writes[(addr, key, idx)] = value - - # Merge balance changes - filter net-zero changes - # balance_changes keyed by (address, index) - for (addr, idx), final_balance in self.balance_changes.items(): - if addr in self.pre_balances: - if self.pre_balances[addr] != final_balance: - # Net change occurred - merge the final balance - self.parent.balance_changes[(addr, idx)] = final_balance - # else: Net-zero change - skip entirely - else: - # No pre-balance captured, merge as-is - self.parent.balance_changes[(addr, idx)] = final_balance - - # Merge nonce changes - keep only highest nonce per address - # Nonces are monotonically increasing, so just keep the max - address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} - for addr, idx, nonce in self.nonce_changes: - # Keep the highest nonce value for each address - if ( - addr not in address_final_nonces - or nonce > address_final_nonces[addr][1] - ): - address_final_nonces[addr] = (idx, nonce) - - # Merge final nonces (no net-zero filtering - nonces never decrease) - for addr, (idx, final_nonce) in address_final_nonces.items(): - self.parent.nonce_changes.add((addr, idx, final_nonce)) - - # Merge code changes - filter net-zero changes - # code_changes keyed by (address, index) - for (addr, idx), final_code in self.code_changes.items(): - if addr in self.pre_code: - if self.pre_code[addr] != final_code: - # Net change occurred - merge the final code - self.parent.code_changes[(addr, idx)] = final_code - # else: Net-zero change - skip entirely + +def get_block_frame(state_changes: StateChanges) -> StateChanges: + """ + Walk to block-level frame. + + Parameters + ---------- + state_changes : + Any state changes frame. + + Returns + ------- + block_frame : StateChanges + The block-level frame. + + """ + block_frame = state_changes + while block_frame.parent is not None: + block_frame = block_frame.parent + return block_frame + + +def get_block_access_index(root_frame: StateChanges) -> BlockAccessIndex: + """ + Get current block access index from root frame. + + Parameters + ---------- + root_frame : + The root (block-level) state changes frame. + + Returns + ------- + index : BlockAccessIndex + The current block access index. + + """ + return root_frame._block_access_index + + +def increment_block_access_index(root_frame: StateChanges) -> None: + """ + Increment block access index in root frame. + + Parameters + ---------- + root_frame : + The root (block-level) state changes frame to increment. + + """ + root_frame._block_access_index = BlockAccessIndex( + root_frame._block_access_index + Uint(1) + ) + + +def capture_pre_balance( + state_changes: StateChanges, address: Address, balance: U256 +) -> None: + """ + Capture pre-balance (first-write-wins for net-zero filtering). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose balance is being captured. + balance : + The balance value before modification. + + """ + if address not in state_changes.pre_balances: + state_changes.pre_balances[address] = balance + + +def capture_pre_nonce( + state_changes: StateChanges, address: Address, nonce: U64 +) -> None: + """ + Capture pre-nonce (first-write-wins). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose nonce is being captured. + nonce : + The nonce value before modification. + + """ + if address not in state_changes.pre_nonces: + state_changes.pre_nonces[address] = nonce + + +def capture_pre_storage( + state_changes: StateChanges, address: Address, key: Bytes32, value: U256 +) -> None: + """ + Capture pre-storage (first-write-wins for noop filtering). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose storage is being captured. + key : + The storage key. + value : + The storage value before modification. + + """ + slot = (address, key) + if slot not in state_changes.pre_storage: + state_changes.pre_storage[slot] = value + + +def capture_pre_code( + state_changes: StateChanges, address: Address, code: Bytes +) -> None: + """ + Capture pre-code (first-write-wins). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose code is being captured. + code : + The code value before modification. + + """ + if address not in state_changes.pre_code: + state_changes.pre_code[address] = code + + +def track_address(state_changes: StateChanges, address: Address) -> None: + """ + Track that an address was accessed. + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address that was accessed. + + """ + state_changes.touched_addresses.add(address) + + +def track_storage_read( + state_changes: StateChanges, address: Address, key: Bytes32 +) -> None: + """ + Track a storage read operation. + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose storage was read. + key : + The storage key that was read. + + """ + state_changes.storage_reads.add((address, key)) + + +def track_storage_write( + state_changes: StateChanges, + address: Address, + key: Bytes32, + value: U256, +) -> None: + """ + Track a storage write operation with block access index. + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose storage was written. + key : + The storage key that was written. + value : + The new storage value. + + """ + block_frame = get_block_frame(state_changes) + state_changes.storage_writes[ + (address, key, get_block_access_index(block_frame)) + ] = value + + +def track_balance_change( + state_changes: StateChanges, + address: Address, + new_balance: U256, +) -> None: + """ + Track balance change keyed by (address, index). + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose balance changed. + new_balance : + The new balance value. + + """ + block_frame = get_block_frame(state_changes) + state_changes.balance_changes[ + (address, get_block_access_index(block_frame)) + ] = new_balance + + +def track_nonce_change( + state_changes: StateChanges, + address: Address, + new_nonce: U64, +) -> None: + """ + Track a nonce change. + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose nonce changed. + new_nonce : + The new nonce value. + + """ + block_frame = get_block_frame(state_changes) + state_changes.nonce_changes.add( + (address, get_block_access_index(block_frame), new_nonce) + ) + + +def track_code_change( + state_changes: StateChanges, + address: Address, + new_code: Bytes, +) -> None: + """ + Track a code change. + + Parameters + ---------- + state_changes : + The state changes frame. + address : + The address whose code changed. + new_code : + The new code value. + + """ + block_frame = get_block_frame(state_changes) + state_changes.code_changes[ + (address, get_block_access_index(block_frame)) + ] = new_code + + +def merge_on_success(child_frame: StateChanges) -> None: + """ + Merge child frame's changes into parent on successful completion. + + Merges all tracked changes (reads and writes) from the child frame + into the parent frame. Filters out net-zero changes based on + captured pre-state values by comparing initial vs final values. + + Parameters + ---------- + child_frame : + The child frame being merged. + + """ + assert child_frame.parent is not None + parent_frame = child_frame.parent + # Merge address accesses + parent_frame.touched_addresses.update(child_frame.touched_addresses) + + # Merge pre-state captures for transaction-level normalization + # Only if parent doesn't have value (first capture wins) + for addr, balance in child_frame.pre_balances.items(): + if addr not in parent_frame.pre_balances: + parent_frame.pre_balances[addr] = balance + for addr, nonce in child_frame.pre_nonces.items(): + if addr not in parent_frame.pre_nonces: + parent_frame.pre_nonces[addr] = nonce + for slot, value in child_frame.pre_storage.items(): + if slot not in parent_frame.pre_storage: + parent_frame.pre_storage[slot] = value + for addr, code in child_frame.pre_code.items(): + if addr not in parent_frame.pre_code: + parent_frame.pre_code[addr] = code + + # Merge storage operations, filtering noop writes + parent_frame.storage_reads.update(child_frame.storage_reads) + for (addr, key, idx), value in child_frame.storage_writes.items(): + # Only merge if value actually changed from pre-state + if (addr, key) in child_frame.pre_storage: + if child_frame.pre_storage[(addr, key)] != value: + parent_frame.storage_writes[(addr, key, idx)] = value + # If equal, it's a noop write - convert to read only else: - # No pre-code captured, merge as-is - self.parent.code_changes[(addr, idx)] = final_code + parent_frame.storage_reads.add((addr, key)) + else: + # No pre-state captured, merge as-is + parent_frame.storage_writes[(addr, key, idx)] = value + + # Merge balance changes - filter net-zero changes + # balance_changes keyed by (address, index) + for (addr, idx), final_balance in child_frame.balance_changes.items(): + if addr in child_frame.pre_balances: + if child_frame.pre_balances[addr] != final_balance: + # Net change occurred - merge the final balance + parent_frame.balance_changes[(addr, idx)] = final_balance + # else: Net-zero change - skip entirely + else: + # No pre-balance captured, merge as-is + parent_frame.balance_changes[(addr, idx)] = final_balance + + # Merge nonce changes - keep only highest nonce per address + # Nonces are monotonically increasing, so just keep the max + address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} + for addr, idx, nonce in child_frame.nonce_changes: + # Keep the highest nonce value for each address + if ( + addr not in address_final_nonces + or nonce > address_final_nonces[addr][1] + ): + address_final_nonces[addr] = (idx, nonce) + + # Merge final nonces (no net-zero filtering - nonces never decrease) + for addr, (idx, final_nonce) in address_final_nonces.items(): + parent_frame.nonce_changes.add((addr, idx, final_nonce)) + + # Merge code changes - filter net-zero changes + # code_changes keyed by (address, index) + for (addr, idx), final_code in child_frame.code_changes.items(): + if addr in child_frame.pre_code: + if child_frame.pre_code[addr] != final_code: + # Net change occurred - merge the final code + parent_frame.code_changes[(addr, idx)] = final_code + # else: Net-zero change - skip entirely + else: + # No pre-code captured, merge as-is + parent_frame.code_changes[(addr, idx)] = final_code + + +def merge_on_failure(child_frame: StateChanges) -> None: + """ + Merge child frame's changes into parent on failed completion. + + Merges only read operations from the child frame into the parent. + Write operations are discarded since the frame reverted. + This is called when a call frame fails/reverts. + + Parameters + ---------- + child_frame : + The failed child frame. + + """ + assert child_frame.parent is not None + parent_frame = child_frame.parent + # Only merge reads and address accesses on failure + parent_frame.touched_addresses.update(child_frame.touched_addresses) + parent_frame.storage_reads.update(child_frame.storage_reads) - def merge_on_failure(self) -> None: - """ - Merge this frame's changes into parent on failed completion. + # Convert writes to reads (failed writes still accessed the slots) + for address, key, _idx in child_frame.storage_writes.keys(): + parent_frame.storage_reads.add((address, key)) - Merges only read operations from this frame into the parent. - Write operations are discarded since the frame reverted. - This is called when a call frame fails/reverts. - """ - if self.parent is None: - return + # Note: balance_changes, nonce_changes, and code_changes are NOT + # merged on failure - they are discarded - # Only merge reads and address accesses on failure - self.parent.touched_addresses.update(self.touched_addresses) - self.parent.storage_reads.update(self.storage_reads) - # Convert writes to reads (failed writes still accessed the slots) - for address, key, _idx in self.storage_writes.keys(): - self.parent.storage_reads.add((address, key)) +def create_child_frame(parent: StateChanges) -> StateChanges: + """ + Create a child frame for nested execution. + + Parameters + ---------- + parent : + The parent frame. + + Returns + ------- + child : StateChanges + A new child frame with parent reference set. - # Note: balance_changes, nonce_changes, and code_changes are NOT - # merged on failure - they are discarded + """ + return StateChanges(parent=parent) def handle_in_transaction_selfdestruct( @@ -336,24 +564,3 @@ def normalize_balance_changes_for_transaction( del block_frame.balance_changes[ (addr, current_block_access_index) ] - - -def create_child_frame(parent: StateChanges) -> StateChanges: - """ - Create a child frame for nested execution. - - The child frame will dynamically read the block_access_index from - the root (block) frame, ensuring all frames see the same current index. - - Parameters - ---------- - parent : StateChanges - The parent frame. - - Returns - ------- - child : StateChanges - A new child frame with parent link. - - """ - return StateChanges(parent=parent) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 04b74eee9e..26b7e99e45 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -25,7 +25,7 @@ from ..blocks import Log, Receipt, Withdrawal from ..fork_types import Address, Authorization, VersionedHash from ..state import State, TransientStorage -from ..state_tracker import StateChanges +from ..state_tracker import StateChanges, merge_on_failure, merge_on_success from ..transactions import LegacyTransaction from ..trie import Trie @@ -187,8 +187,7 @@ def incorporate_child_on_success(evm: Evm, child_evm: Evm) -> None: evm.accessed_addresses.update(child_evm.accessed_addresses) evm.accessed_storage_keys.update(child_evm.accessed_storage_keys) - # Merge state changes from successful child frame (EIP-7928) - child_evm.state_changes.merge_on_success() + merge_on_success(child_evm.state_changes) def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: @@ -205,6 +204,4 @@ def incorporate_child_on_error(evm: Evm, child_evm: Evm) -> None: """ evm.gas_left += child_evm.gas_left - # Merge state changes from failed child frame (EIP-7928) - # Only reads are merged, writes are discarded - child_evm.state_changes.merge_on_failure() + merge_on_failure(child_evm.state_changes) diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index bce49462f2..cd1f24a70c 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -15,6 +15,7 @@ # track_address_access removed - now using state_changes.track_address() from ..fork_types import Address, Authorization from ..state import account_exists, get_account, increment_nonce, set_code +from ..state_tracker import capture_pre_code, track_address from ..utils.hexadecimal import hex_to_address from ..vm.gas import GAS_COLD_ACCOUNT_ACCESS, GAS_WARM_ACCESS from . import Evm, Message @@ -175,12 +176,12 @@ def apply_delegation_tracking( The address delegated to. """ - evm.state_changes.track_address(original_address) + track_address(evm.state_changes, original_address) if delegated_address not in evm.accessed_addresses: evm.accessed_addresses.add(delegated_address) - evm.state_changes.track_address(delegated_address) + track_address(evm.state_changes, delegated_address) def access_delegation( @@ -239,7 +240,7 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code - message.block_env.block_state_changes.track_address(authority) + track_address(message.block_env.block_state_changes, authority) if authority_code and not is_valid_delegation(authority_code): continue @@ -256,14 +257,12 @@ def set_delegation(message: Message) -> U256: else: code_to_set = EOA_DELEGATION_MARKER + auth.address - # Use transaction frame, not block frame (EIP-7928) state_changes = ( message.transaction_state_changes or message.block_env.block_state_changes ) - # Capture pre-code just before setting to enable no-op filtering - state_changes.capture_pre_code(authority, authority_code) + capture_pre_code(state_changes, authority, authority_code) set_code(state, authority, code_to_set, state_changes) increment_nonce(state, authority, state_changes) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/environment.py b/src/ethereum/forks/amsterdam/vm/instructions/environment.py index dae8c20280..3d23b8f136 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/environment.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/environment.py @@ -20,6 +20,7 @@ # track_address_access removed - now using state_changes.track_address() from ...fork_types import EMPTY_ACCOUNT from ...state import get_account +from ...state_tracker import track_address from ...utils.address import to_address_masked from ...vm.memory import buffer_read, memory_write from .. import Evm @@ -83,7 +84,7 @@ def balance(evm: Evm) -> None: check_gas(evm, gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - evm.state_changes.track_address(address) + track_address(evm.state_changes, address) charge_gas(evm, gas_cost) # OPERATION @@ -353,7 +354,7 @@ def extcodesize(evm: Evm) -> None: check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - evm.state_changes.track_address(address) + track_address(evm.state_changes, address) charge_gas(evm, access_gas_cost) # OPERATION @@ -399,7 +400,7 @@ def extcodecopy(evm: Evm) -> None: check_gas(evm, total_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - evm.state_changes.track_address(address) + track_address(evm.state_changes, address) charge_gas(evm, total_gas_cost) # OPERATION @@ -493,7 +494,7 @@ def extcodehash(evm: Evm) -> None: check_gas(evm, access_gas_cost) if is_cold_access: evm.accessed_addresses.add(address) - evm.state_changes.track_address(address) + track_address(evm.state_changes, address) charge_gas(evm, access_gas_cost) # OPERATION diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index db1536a707..8edff23534 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -20,6 +20,11 @@ set_storage, set_transient_storage, ) +from ...state_tracker import ( + capture_pre_storage, + track_storage_read, + track_storage_write, +) from .. import Evm from ..exceptions import OutOfGasError, WriteInStaticContext from ..gas import ( @@ -58,7 +63,8 @@ def sload(evm: Evm) -> None: check_gas(evm, gas_cost) if (evm.message.current_target, key) not in evm.accessed_storage_keys: evm.accessed_storage_keys.add((evm.message.current_target, key)) - evm.state_changes.track_storage_read( + track_storage_read( + evm.state_changes, evm.message.current_target, key, ) @@ -120,10 +126,11 @@ def sstore(evm: Evm) -> None: # Track storage access BEFORE checking gas (EIP-7928) # Even if we run out of gas, the access attempt should be tracked - evm.state_changes.capture_pre_storage( - evm.message.current_target, key, current_value + capture_pre_storage( + evm.state_changes, evm.message.current_target, key, current_value ) - evm.state_changes.track_storage_read( + track_storage_read( + evm.state_changes, evm.message.current_target, key, ) @@ -157,10 +164,8 @@ def sstore(evm: Evm) -> None: # OPERATION set_storage(state, evm.message.current_target, key, new_value) - evm.state_changes.track_storage_write( - evm.message.current_target, - key, - new_value, + track_storage_write( + evm.state_changes, evm.message.current_target, key, new_value ) # PROGRAM COUNTER diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 136f194655..389adf6f54 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -27,6 +27,7 @@ move_ether, set_account_balance, ) +from ...state_tracker import capture_pre_balance, track_address from ...utils.address import ( compute_contract_address, compute_create2_contract_address, @@ -114,24 +115,16 @@ def generic_create( evm.accessed_addresses.add(contract_address) - evm.state_changes.track_address(contract_address) + track_address(evm.state_changes, contract_address) if account_has_code_or_nonce( state, contract_address ) or account_has_storage(state, contract_address): - increment_nonce( - state, - evm.message.current_target, - evm.state_changes, - ) + increment_nonce(state, evm.message.current_target, evm.state_changes) push(evm.stack, U256(0)) return - increment_nonce( - state, - evm.message.current_target, - evm.state_changes, - ) + increment_nonce(state, evm.message.current_target, evm.state_changes) child_message = Message( block_env=evm.message.block_env, @@ -430,7 +423,7 @@ def call(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - evm.state_changes.track_address(to) + track_address(evm.state_changes, to) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -527,7 +520,7 @@ def callcode(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - evm.state_changes.track_address(original_address) + track_address(evm.state_changes, original_address) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -543,8 +536,8 @@ def callcode(evm: Evm) -> None: # in parent frame. CALLCODE transfers value from/to current_target # (same address), affecting current storage context, not child frame if value != 0 and sender_balance >= value: - evm.state_changes.capture_pre_balance( - evm.message.current_target, sender_balance + capture_pre_balance( + evm.state_changes, evm.message.current_target, sender_balance ) if sender_balance < value: @@ -609,7 +602,7 @@ def selfdestruct(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(beneficiary) - evm.state_changes.track_address(beneficiary) + track_address(evm.state_changes, beneficiary) charge_gas(evm, gas_cost) @@ -629,13 +622,8 @@ def selfdestruct(evm: Evm) -> None: # register account for deletion only if it was created # in the same transaction if originator in evm.message.block_env.state.created_accounts: - # If beneficiary is the same as originator, then - # the ether is burnt. set_account_balance( - evm.message.block_env.state, - originator, - U256(0), - evm.state_changes, + evm.message.block_env.state, originator, U256(0), evm.state_changes ) evm.accounts_to_delete.add(originator) @@ -698,7 +686,7 @@ def delegatecall(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - evm.state_changes.track_address(original_address) + track_address(evm.state_changes, original_address) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) @@ -783,7 +771,7 @@ def staticcall(evm: Evm) -> None: check_gas(evm, message_call_gas.cost + extend_memory.cost) - evm.state_changes.track_address(to) + track_address(evm.state_changes, to) if is_delegated: apply_delegation_tracking(evm, original_address, final_address) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 9d6190b0e8..a63b745624 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -44,7 +44,13 @@ rollback_transaction, set_code, ) -from ..state_tracker import StateChanges, create_child_frame +from ..state_tracker import ( + StateChanges, + create_child_frame, + merge_on_failure, + merge_on_success, + track_address, +) from ..vm import Message from ..vm.eoa_delegation import get_delegated_code_address, set_delegation from ..vm.gas import GAS_CODE_DEPOSIT, charge_gas @@ -67,35 +73,39 @@ MAX_INIT_CODE_SIZE = 2 * MAX_CODE_SIZE -def create_call_frame(parent_frame: StateChanges) -> StateChanges: +def get_parent_frame(message: Message) -> StateChanges: """ - Create a child frame for call-level state tracking. + Get the appropriate parent frame for a message's state changes. - Used for contract calls (CALL, DELEGATECALL, STATICCALL, etc.) where - state changes need to be isolated and potentially reverted. + Frame selection logic: + - Nested calls: Parent EVM's frame + - Top-level calls: Transaction frame + - System transactions: Block frame Parameters ---------- - parent_frame : - The parent frame (transaction or another call frame). + message : + The message being processed. Returns ------- - call_frame : StateChanges - A new child frame linked to the parent. + parent_frame : StateChanges + The parent frame to use for creating child frames. """ - return create_child_frame(parent_frame) + if message.parent_evm is not None: + return message.parent_evm.state_changes + elif message.transaction_state_changes is not None: + return message.transaction_state_changes + else: + return message.block_env.block_state_changes def get_message_state_frame(message: Message) -> StateChanges: """ Determine and create the appropriate state tracking frame for a message. - Frame selection logic: - - Nested calls: Create child of parent EVM's frame - - Top-level calls: Create child of transaction frame - - System transactions: Use block frame directly (no isolation needed) + Creates a call frame as a child of the appropriate parent frame. Parameters ---------- @@ -108,16 +118,14 @@ def get_message_state_frame(message: Message) -> StateChanges: The state tracking frame to use for this message execution. """ - if message.parent_evm is not None: - # Nested call - create child of parent EVM's frame - return create_call_frame(message.parent_evm.state_changes) - elif message.transaction_state_changes is not None: - # Top-level transaction call - create child of transaction frame - # This ensures contract execution is isolated and can be reverted - return create_call_frame(message.transaction_state_changes) + parent_frame = get_parent_frame(message) + if ( + message.parent_evm is not None + or message.transaction_state_changes is not None + ): + return create_child_frame(parent_frame) else: - # System transaction - use block frame directly - return message.block_env.block_state_changes + return parent_frame @dataclass @@ -188,8 +196,8 @@ def process_message_call(message: Message) -> MessageCallOutput: message.code_address = delegated_address # EIP-7928: Track delegation target when loaded as call target - message.block_env.block_state_changes.track_address( - delegated_address + track_address( + message.block_env.block_state_changes, delegated_address ) evm = process_message(message) @@ -252,9 +260,8 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - # Create a temporary child frame for tracking changes that may be rolled - # back on OOG during code deposit. This frame is merged only on success. - create_frame = create_child_frame(message.block_env.block_state_changes) + parent_frame = get_parent_frame(message) + create_frame = create_child_frame(parent_frame) increment_nonce(state, message.current_target, create_frame) evm = process_message(message) @@ -270,24 +277,19 @@ def process_create_message(message: Message) -> Evm: raise OutOfGasError except ExceptionalHalt as error: rollback_transaction(state, transient_storage) - # Merge create_frame on failure - keeps reads, discards writes - # (address access is preserved, nonce change is discarded) - create_frame.merge_on_failure() + merge_on_failure(create_frame) evm.gas_left = Uint(0) evm.output = b"" evm.error = error else: set_code( - state, - message.current_target, - contract_code, - create_frame, + state, message.current_target, contract_code, create_frame ) commit_transaction(state, transient_storage) - # Merge create_frame on success - includes nonce and code changes - create_frame.merge_on_success() + merge_on_success(create_frame) else: rollback_transaction(state, transient_storage) + merge_on_failure(create_frame) return evm @@ -313,8 +315,10 @@ def process_message(message: Message) -> Evm: begin_transaction(state, transient_storage) + parent_frame = get_parent_frame(message) state_changes = get_message_state_frame(message) - state_changes.track_address(message.current_target) + + track_address(state_changes, message.current_target) if message.should_transfer_value and message.value != 0: move_ether( @@ -327,15 +331,13 @@ def process_message(message: Message) -> Evm: evm = execute_code(message, state_changes) if evm.error: - # revert state to the last saved checkpoint - # since the message call resulted in an error rollback_transaction(state, transient_storage) - # Merge call frame state changes into parent - evm.state_changes.merge_on_failure() + if state_changes != parent_frame: + merge_on_failure(evm.state_changes) else: commit_transaction(state, transient_storage) - # Merge call frame state changes into parent - evm.state_changes.merge_on_success() + if state_changes != parent_frame: + merge_on_success(evm.state_changes) return evm diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 2100983144..2d3f8b9e3c 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -413,7 +413,11 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: # Post-execution operations use index N+1 if self.fork.is_after_fork("amsterdam"): - block_env.block_state_changes.increment_index() + from ethereum.forks.amsterdam.state_tracker import ( + increment_block_access_index, + ) + + increment_block_access_index(block_env.block_state_changes) if not self.fork.proof_of_stake: if self.options.state_reward is None: diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index de354bbeb1..b849137c58 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -910,3 +910,62 @@ def test_bal_sstore_static_context( contract_b: Account(storage={0: 0}), # SSTORE failed }, ) + + +def test_bal_create_contract_init_revert( + blockchain_test: BlockchainTestFiller, + pre: Alloc, +) -> None: + """ + Test that BAL does not include nonce/code changes when CREATE happens + in a call that then REVERTs. + """ + alice = pre.fund_eoa(amount=10**18) + + # Simple init code that returns STOP as deployed code + init_code_bytes = bytes(Op.RETURN(0, 1) + Op.STOP) + + # Factory that does CREATE then REVERTs + factory = pre.deploy_contract( + code=Op.MSTORE(0, Op.PUSH32(init_code_bytes)) + + Op.POP(Op.CREATE(0, 32 - len(init_code_bytes), len(init_code_bytes))) + + Op.REVERT(0, 0) + ) + + # A caller that CALLs factory to CREATE then REVERT + caller = pre.deploy_contract(code=Op.CALL(address=factory)) + + created_address = compute_create_address(address=factory, nonce=1) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=500_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=1, post_nonce=1) + ], + ), + caller: BalAccountExpectation.empty(), + factory: BalAccountExpectation.empty(), + created_address: BalAccountExpectation.empty(), + } + ), + ) + ], + post={ + alice: Account(nonce=1), + caller: Account(nonce=1), + factory: Account(nonce=1), + created_address: Account.NONEXISTENT, + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index bc45ef2d21..4ca8a63ecd 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -52,6 +52,7 @@ | `test_bal_oog_7702_delegated_warm_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when first account is warm, second is cold | Alice calls warm delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (warm load succeeds) but **MUST NOT** include `TargetContract` (cold load fails due to OOG) | 🟡 Planned | | `test_bal_multiple_balance_changes_same_account` | Ensure BAL tracks multiple balance changes to same account across transactions | Alice funds Bob (starts at 0) in tx0 with exact amount needed. Bob spends everything in tx1 to Charlie. Bob's balance: 0 → funding_amount → 0 | BAL **MUST** include Bob with two `balance_changes`: one at txIndex=1 (receives funds) and one at txIndex=2 (balance returns to 0). This tests balance tracking across two transactions. | ✅ Completed | | `test_bal_multiple_storage_writes_same_slot` | Ensure BAL tracks multiple writes to same storage slot across transactions | Alice calls contract 3 times in same block. Contract increments slot 1 on each call: 0 → 1 → 2 → 3 | BAL **MUST** include contract with slot 1 having three `slot_changes`: txIndex=1 (value 1), txIndex=2 (value 2), txIndex=3 (value 3). Each transaction's write must be recorded separately. | ✅ Completed | +| `test_bal_create_contract_init_revert` | Ensure BAL correctly handles CREATE when parent call reverts | Caller calls factory, factory executes CREATE (succeeds), then factory REVERTs rolling back the CREATE | BAL **MUST** include Alice with `nonce_changes`. Caller and factory with no changes (reverted). Created contract address appears in BAL but **MUST NOT** have `nonce_changes` or `code_changes` (CREATE was rolled back). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_create_oog_code_deposit` | Ensure BAL correctly handles CREATE OOG during code deposit | Alice calls factory contract that executes CREATE with init code returning 10,000 bytes. Transaction has insufficient gas for code deposit. Factory nonce increments, CREATE returns 0 and stores in slot 1. | BAL **MUST** include Alice with `nonce_changes`. Factory with `nonce_changes` (incremented by CREATE) and `storage_changes` (slot 1 = 0). Contract address with empty changes (read during collision check). **MUST NOT** include nonce or code changes for contract address (rolled back on OOG). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_invalid_missing_nonce` | Verify clients reject blocks with BAL missing required nonce changes | Alice sends transaction to Bob; BAL modifier removes Alice's nonce change entry | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that all sender accounts have nonce changes recorded. | ✅ Completed | | `test_bal_invalid_nonce_value` | Verify clients reject blocks with incorrect nonce values in BAL | Alice sends transaction to Bob; BAL modifier changes Alice's nonce to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate nonce values match actual state transitions. | ✅ Completed | From 362e01ab168198e260d60572d73c1663b0b8a8cb Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 14 Nov 2025 10:08:31 -0300 Subject: [PATCH 22/51] fix(spec-specs): Default code to b"" in tracker, skip empty setting --- src/ethereum/forks/amsterdam/state_tracker.py | 11 ++--- .../test_block_access_lists.py | 45 +++++++++++++++++++ .../test_cases.md | 1 + 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 2e9385d272..e157588dac 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -393,7 +393,6 @@ def merge_on_success(child_frame: StateChanges) -> None: for (addr, idx), final_balance in child_frame.balance_changes.items(): if addr in child_frame.pre_balances: if child_frame.pre_balances[addr] != final_balance: - # Net change occurred - merge the final balance parent_frame.balance_changes[(addr, idx)] = final_balance # else: Net-zero change - skip entirely else: @@ -418,14 +417,10 @@ def merge_on_success(child_frame: StateChanges) -> None: # Merge code changes - filter net-zero changes # code_changes keyed by (address, index) for (addr, idx), final_code in child_frame.code_changes.items(): - if addr in child_frame.pre_code: - if child_frame.pre_code[addr] != final_code: - # Net change occurred - merge the final code - parent_frame.code_changes[(addr, idx)] = final_code - # else: Net-zero change - skip entirely - else: - # No pre-code captured, merge as-is + pre_code = child_frame.pre_code.get(addr, b"") + if pre_code != final_code: parent_frame.code_changes[(addr, idx)] = final_code + # else: Net-zero change - skip entirely def merge_on_failure(child_frame: StateChanges) -> None: diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index baead4938e..7a342b98d5 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -2088,3 +2088,48 @@ def test_bal_multiple_storage_writes_same_slot( contract: Account(storage={1: 3}), }, ) + + +def test_bal_create_transaction_empty_code( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL does not record spurious code changes when a CREATE transaction + deploys empty code. + """ + alice = pre.fund_eoa() + contract_address = compute_create_address(address=alice, nonce=0) + + tx = Transaction( + sender=alice, + to=None, + data=b"", + gas_limit=100_000, + ) + + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + contract_address: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + code_changes=[], # ensure no code_changes recorded + ), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=1), + contract_address: Account(nonce=1, code=b""), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 4ca8a63ecd..44211095a0 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -85,3 +85,4 @@ | `test_bal_nonexistent_account_access_value_transfer` | Ensure BAL captures non-existent account accessed via CALL/CALLCODE with value transfers | Alice calls `Oracle` contract which uses `CALL` or `CALLCODE` on non-existent account Bob. Tests both zero and positive value transfers. | BAL **MUST** include Alice with `nonce_changes`. For CALL with positive value: `Oracle` with `balance_changes` (loses value), Bob with `balance_changes` (receives value). For CALLCODE with value or zero value transfers: `Oracle` and Bob with empty changes (CALLCODE self-transfer = net zero). | ✅ Completed | | `test_bal_storage_write_read_same_frame` | Ensure BAL captures write precedence over read in same call frame (writes shadow reads) | Alice calls `Oracle` which writes (`SSTORE`) value `0x42` to slot `0x01`, then reads (`SLOAD`) from slot `0x01` in the same call frame | BAL **MUST** include `Oracle` with slot `0x01` in `storage_changes` showing final value `0x42`. Slot `0x01` **MUST NOT** appear in `storage_reads` (write shadows the subsequent read in same frame). | ✅ Completed | | `test_bal_storage_write_read_cross_frame` | Ensure BAL captures write precedence over read across call frames (writes shadow reads cross-frame) | Alice calls `Oracle`. First call reads slot `0x01` (sees initial value), writes `0x42` to slot `0x01`, then calls itself (via `CALL`, `DELEGATECALL`, or `CALLCODE`). Second call reads slot `0x01` (sees `0x42`) and exits. | BAL **MUST** include `Oracle` with slot `0x01` in `storage_changes` showing final value `0x42`. Slot `0x01` **MUST NOT** appear in `storage_reads` (write shadows both the read before it in same frame and the read in the recursive call). | ✅ Completed | +| `test_bal_create_transaction_empty_code` | Ensure BAL does not record spurious code changes for CREATE transaction deploying empty code | Alice sends CREATE transaction with empty initcode (deploys code `b""`). Contract address gets nonce = 1 and code = `b""`. | BAL **MUST** include Alice with `nonce_changes` and created contract with `nonce_changes` but **MUST NOT** include `code_changes` for contract. | ✅ Completed | From 05c122ee83895e0781ac54f7d5a9276a7a9fc600 Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 14 Nov 2025 12:06:16 -0300 Subject: [PATCH 23/51] fix(spec-specs): Fix BAL cross-transaction tracking and nonce dedup - add commit_transaction_frame() - no net-zero filtering for cross-tx changes - keep max nonce per transaction when building BAL, remove block-level code filtering - filter net-zero code changes at tracking time (for 7702 txs) - use commit_transaction_frame() instead of merge_on_success() for tx->block commits --- .../amsterdam/block_access_lists/builder.py | 13 ++-- src/ethereum/forks/amsterdam/fork.py | 4 +- src/ethereum/forks/amsterdam/state.py | 7 +- src/ethereum/forks/amsterdam/state_tracker.py | 44 ++++++++++- .../test_block_access_lists.py | 73 +++++++++++++++++++ .../test_cases.md | 2 + 6 files changed, 133 insertions(+), 10 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index 4ed7aa767c..9af8d2a24c 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -278,14 +278,15 @@ def add_nonce_change( ensure_account(builder, address) # Check if we already have a nonce change for this tx_index and update it - # This ensures we only track the final nonce per transaction + # This ensures we only track the final (highest) nonce per transaction existing_changes = builder.accounts[address].nonce_changes for i, existing in enumerate(existing_changes): if existing.block_access_index == block_access_index: - # Update the existing nonce change with the new nonce - existing_changes[i] = NonceChange( - block_access_index=block_access_index, new_nonce=new_nonce - ) + # Keep the highest nonce value + if new_nonce > existing.new_nonce: + existing_changes[i] = NonceChange( + block_access_index=block_access_index, new_nonce=new_nonce + ) return # No existing change for this tx_index, add a new one @@ -514,6 +515,8 @@ def build_block_access_list( add_nonce_change(builder, address, block_access_index, new_nonce) # Add all code changes + # Net-zero filtering for code changes should happen at the + # transaction level (in merge_on_success), not at the block level. for ( address, block_access_index, diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index dcc46d1c4f..fa4af90fd8 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -66,11 +66,11 @@ ) from .state_tracker import ( capture_pre_balance, + commit_transaction_frame, create_child_frame, get_block_access_index, handle_in_transaction_selfdestruct, increment_block_access_index, - merge_on_success, normalize_balance_changes_for_transaction, track_address, track_balance_change, @@ -1067,7 +1067,7 @@ def process_transaction( block_env.state, ) - merge_on_success(tx_state_changes) + commit_transaction_frame(tx_state_changes) # EIP-7928: Handle in-transaction self-destruct AFTER merge # Convert storage writes to reads and remove nonce/code changes diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index af384ec4df..d16c9d9d69 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -647,7 +647,12 @@ def write_code(sender: Account) -> None: sender.code = code modify_state(state, address, write_code) - track_code_change(state_changes, address, code) + + # Only track code change if it's not net-zero within this frame + # Compare against pre-code captured in this frame, default to b"" + pre_code = state_changes.pre_code.get(address, b"") + if pre_code != code: + track_code_change(state_changes, address, code) def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index e157588dac..58ca854bf8 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -400,10 +400,8 @@ def merge_on_success(child_frame: StateChanges) -> None: parent_frame.balance_changes[(addr, idx)] = final_balance # Merge nonce changes - keep only highest nonce per address - # Nonces are monotonically increasing, so just keep the max address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} for addr, idx, nonce in child_frame.nonce_changes: - # Keep the highest nonce value for each address if ( addr not in address_final_nonces or nonce > address_final_nonces[addr][1] @@ -423,6 +421,48 @@ def merge_on_success(child_frame: StateChanges) -> None: # else: Net-zero change - skip entirely +def commit_transaction_frame(tx_frame: StateChanges) -> None: + """ + Commit a transaction frame's changes to the block frame. + + Merges ALL changes from the transaction frame into the block frame + without net-zero filtering. Each transaction's changes are recorded + at their respective transaction index, even if a later transaction + reverts a change back to its original value. + + This is different from merge_on_success() which filters net-zero + changes within a single transaction's execution. + + Parameters + ---------- + tx_frame : + The transaction frame to commit. + + """ + assert tx_frame.parent is not None + block_frame = tx_frame.parent + + # Merge address accesses + block_frame.touched_addresses.update(tx_frame.touched_addresses) + + # Merge storage operations + block_frame.storage_reads.update(tx_frame.storage_reads) + for (addr, key, idx), value in tx_frame.storage_writes.items(): + block_frame.storage_writes[(addr, key, idx)] = value + + # Merge balance changes + for (addr, idx), final_balance in tx_frame.balance_changes.items(): + block_frame.balance_changes[(addr, idx)] = final_balance + + # Merge nonce changes + for addr, idx, nonce in tx_frame.nonce_changes: + block_frame.nonce_changes.add((addr, idx, nonce)) + + # Merge code changes + for (addr, idx), final_code in tx_frame.code_changes.items(): + block_frame.code_changes[(addr, idx)] = final_code + + def merge_on_failure(child_frame: StateChanges) -> None: """ Merge child frame's changes into parent on failed completion. diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 7a342b98d5..14a9ab4bdf 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -2133,3 +2133,76 @@ def test_bal_create_transaction_empty_code( contract_address: Account(nonce=1, code=b""), }, ) + + +def test_bal_cross_tx_storage_revert_to_zero( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures storage changes when tx1 writes a non-zero value + and tx2 reverts it back to zero. This is a regression test for the + blobhash scenario where slot changes were being incorrectly filtered + as net-zero across transaction boundaries. + + Tx1: slot 0 = 0x0 -> 0xABCD (change recorded at tx_index=1) + Tx2: slot 0 = 0xABCD -> 0x0 (change MUST be recorded at tx_index=2) + """ + alice = pre.fund_eoa() + + # Contract that writes to slot 0 based on calldata + contract = pre.deploy_contract(code=Op.SSTORE(0, Op.CALLDATALOAD(0))) + + # Tx1: Write slot 0 = 0xABCD + tx1 = Transaction( + sender=alice, + to=contract, + data=Hash(0xABCD), + gas_limit=100_000, + ) + + # Tx2: Write slot 0 = 0x0 (revert to zero) + tx2 = Transaction( + sender=alice, + to=contract, + data=Hash(0x0), + gas_limit=100_000, + ) + + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=1, post_nonce=1), + BalNonceChange(tx_index=2, post_nonce=2), + ], + ), + contract: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0xABCD), + # CRITICAL: tx2's write to 0x0 MUST appear + # even though it returns slot to original value + BalStorageChange(tx_index=2, post_value=0x0), + ], + ), + ], + ), + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx1, tx2], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=2), + contract: Account(storage={0: 0x0}), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 44211095a0..5f80be1fbc 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -52,6 +52,8 @@ | `test_bal_oog_7702_delegated_warm_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when first account is warm, second is cold | Alice calls warm delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (warm load succeeds) but **MUST NOT** include `TargetContract` (cold load fails due to OOG) | 🟡 Planned | | `test_bal_multiple_balance_changes_same_account` | Ensure BAL tracks multiple balance changes to same account across transactions | Alice funds Bob (starts at 0) in tx0 with exact amount needed. Bob spends everything in tx1 to Charlie. Bob's balance: 0 → funding_amount → 0 | BAL **MUST** include Bob with two `balance_changes`: one at txIndex=1 (receives funds) and one at txIndex=2 (balance returns to 0). This tests balance tracking across two transactions. | ✅ Completed | | `test_bal_multiple_storage_writes_same_slot` | Ensure BAL tracks multiple writes to same storage slot across transactions | Alice calls contract 3 times in same block. Contract increments slot 1 on each call: 0 → 1 → 2 → 3 | BAL **MUST** include contract with slot 1 having three `slot_changes`: txIndex=1 (value 1), txIndex=2 (value 2), txIndex=3 (value 3). Each transaction's write must be recorded separately. | ✅ Completed | +| `test_bal_create_transaction_empty_code` | Ensure BAL does not record spurious code changes for CREATE transaction deploying empty code | Alice sends CREATE transaction with empty initcode (deploys code `b""`). Contract address gets nonce = 1 and code = `b""`. | BAL **MUST** include Alice with `nonce_changes` and created contract with `nonce_changes` but **MUST NOT** include `code_changes` for contract (setting `b"" -> b""` is net-zero). | ✅ Completed | +| `test_bal_cross_tx_storage_revert_to_zero` | Ensure BAL captures storage changes when tx2 reverts slot back to original value (blobhash regression test) | Alice sends tx1 writing slot 0=0xABCD (from 0x0), then tx2 writing slot 0=0x0 (back to original) | BAL **MUST** include contract with slot 0 having two `slot_changes`: txIndex=1 (0xABCD) and txIndex=2 (0x0). Cross-transaction net-zero **MUST NOT** be filtered. | ✅ Completed | | `test_bal_create_contract_init_revert` | Ensure BAL correctly handles CREATE when parent call reverts | Caller calls factory, factory executes CREATE (succeeds), then factory REVERTs rolling back the CREATE | BAL **MUST** include Alice with `nonce_changes`. Caller and factory with no changes (reverted). Created contract address appears in BAL but **MUST NOT** have `nonce_changes` or `code_changes` (CREATE was rolled back). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_create_oog_code_deposit` | Ensure BAL correctly handles CREATE OOG during code deposit | Alice calls factory contract that executes CREATE with init code returning 10,000 bytes. Transaction has insufficient gas for code deposit. Factory nonce increments, CREATE returns 0 and stores in slot 1. | BAL **MUST** include Alice with `nonce_changes`. Factory with `nonce_changes` (incremented by CREATE) and `storage_changes` (slot 1 = 0). Contract address with empty changes (read during collision check). **MUST NOT** include nonce or code changes for contract address (rolled back on OOG). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_invalid_missing_nonce` | Verify clients reject blocks with BAL missing required nonce changes | Alice sends transaction to Bob; BAL modifier removes Alice's nonce change entry | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that all sender accounts have nonce changes recorded. | ✅ Completed | From 8ceade2c10458062c3fe2af4d55dbeed3c321ac9 Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 14 Nov 2025 14:29:24 -0300 Subject: [PATCH 24/51] fix(spec-specs): Move destroy_account before BAL normalization --- src/ethereum/forks/amsterdam/fork.py | 13 ++++-- .../test_selfdestruct_revert.py | 41 ++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index fa4af90fd8..9abb065f0e 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -1057,8 +1057,15 @@ def process_transaction( block_output.block_logs += tx_output.logs + # EIP-7928: Handle in-transaction self-destruct BEFORE normalization + # Destroy accounts first so normalization sees correct post-tx state + # Only accounts created in same tx are in accounts_to_delete per EIP-6780 + for address in tx_output.accounts_to_delete: + destroy_account(block_env.state, address) + # EIP-7928: Normalize balance changes for this transaction before merging - # into block frame. + # into block frame. Must happen AFTER destroy_account so net-zero filtering + # sees the correct post-transaction balance (0 for destroyed accounts). normalize_balance_changes_for_transaction( tx_state_changes, BlockAccessIndex( @@ -1069,9 +1076,8 @@ def process_transaction( commit_transaction_frame(tx_state_changes) - # EIP-7928: Handle in-transaction self-destruct AFTER merge + # EIP-7928: Handle in-transaction self-destruct normalization AFTER merge # Convert storage writes to reads and remove nonce/code changes - # Only accounts created in same tx are in accounts_to_delete per EIP-6780 for address in tx_output.accounts_to_delete: handle_in_transaction_selfdestruct( block_env.block_state_changes, @@ -1080,7 +1086,6 @@ def process_transaction( get_block_access_index(block_env.block_state_changes) ), ) - destroy_account(block_env.state, address) def process_withdrawals( diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py index e0bfa59ec2..2c7bbaea3e 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py @@ -8,8 +8,12 @@ Account, Address, Alloc, + BalAccountExpectation, + BalBalanceChange, + BlockAccessListExpectation, Bytecode, Environment, + Fork, Initcode, Op, StateTestFiller, @@ -343,6 +347,7 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 selfdestruct_with_transfer_initcode_copy_from_address: Address, recursive_revert_contract_address: Address, recursive_revert_contract_code: Bytecode, + fork: Fork, ) -> None: """ Given: @@ -427,7 +432,41 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 gas_limit=500_000, ) - state_test(env=env, pre=pre, post=post, tx=tx) + expected_block_access_list = None + if fork.header_bal_hash_required(): + account_expectations = {} + + if selfdestruct_on_outer_call > 0: + account_expectations[ + selfdestruct_with_transfer_contract_address + ] = BalAccountExpectation( + storage_reads=[0, 1], # Storage was accessed + balance_changes=[], # No net balance change + ) + account_expectations[selfdestruct_recipient_address] = ( + BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=1, + post_balance=1 + if selfdestruct_on_outer_call == 1 + else 2, + ) + ], + ) + ) + + expected_block_access_list = BlockAccessListExpectation( + account_expectations=account_expectations + ) + + state_test( + env=env, + pre=pre, + post=post, + tx=tx, + expected_block_access_list=expected_block_access_list, + ) @pytest.mark.parametrize( From 5a4a373def253ebc2f07241084b33b6123612591 Mon Sep 17 00:00:00 2001 From: fselmo Date: Fri, 14 Nov 2025 17:52:41 -0300 Subject: [PATCH 25/51] fix(spec-specs): Check delegation access gas before reading --- .../forks/amsterdam/vm/eoa_delegation.py | 81 +++++------- .../forks/amsterdam/vm/instructions/system.py | 123 ++++++++++++------ tests/prague/eip7702_set_code_tx/test_gas.py | 23 ++++ 3 files changed, 144 insertions(+), 83 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index cd1f24a70c..72d6d32215 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -116,11 +116,15 @@ def recover_authority(authorization: Authorization) -> Address: return Address(keccak256(public_key)[12:32]) -def check_delegation( +def calculate_delegation_cost( evm: Evm, address: Address -) -> Tuple[bool, Address, Address, Bytes, Uint]: +) -> Tuple[bool, Address, Optional[Address], Uint]: """ - Check delegation info without modifying state or tracking. + Check if address has delegation and calculate delegation target gas cost. + + This function reads the original account's code to check for delegation + and tracks it in state_changes. It calculates the delegation target's + gas cost but does NOT read the delegation target yet. Parameters ---------- @@ -131,77 +135,64 @@ def check_delegation( Returns ------- - delegation : `Tuple[bool, Address, Address, Bytes, Uint]` - (is_delegated, original_address, final_address, code, - additional_gas_cost) + delegation_info : `Tuple[bool, Address, Optional[Address], Uint]` + (is_delegated, original_address, delegated_address_or_none, + delegation_gas_cost) """ state = evm.message.block_env.state code = get_account(state, address).code + track_address(evm.state_changes, address) + if not is_valid_delegation(code): - return False, address, address, code, Uint(0) + return False, address, None, Uint(0) delegated_address = Address(code[EOA_DELEGATION_MARKER_LENGTH:]) + # Calculate gas cost for delegation target access if delegated_address in evm.accessed_addresses: - additional_gas_cost = GAS_WARM_ACCESS + delegation_gas_cost = GAS_WARM_ACCESS else: - additional_gas_cost = GAS_COLD_ACCOUNT_ACCESS + delegation_gas_cost = GAS_COLD_ACCOUNT_ACCESS - delegated_code = get_account(state, delegated_address).code + return True, address, delegated_address, delegation_gas_cost - return ( - True, - address, - delegated_address, - delegated_code, - additional_gas_cost, - ) - -def apply_delegation_tracking( - evm: Evm, original_address: Address, delegated_address: Address -) -> None: +def read_delegation_target(evm: Evm, delegated_address: Address) -> Bytes: """ - Apply delegation tracking after gas check passes. + Read the delegation target's code and track the access. + + Should ONLY be called AFTER verifying we have gas for the access. + + This function: + 1. Reads the delegation target's code from state + 2. Adds it to accessed_addresses (if not already there) + 3. Tracks it in state_changes for BAL Parameters ---------- evm : `Evm` The execution frame. - original_address : `Address` - The original address that was called. delegated_address : `Address` - The address delegated to. + The delegation target address. + + Returns + ------- + code : `Bytes` + The delegation target's code. """ - track_address(evm.state_changes, original_address) + state = evm.message.block_env.state + # Add to accessed addresses for warm/cold gas accounting if delegated_address not in evm.accessed_addresses: evm.accessed_addresses.add(delegated_address) + # Track the address for BAL track_address(evm.state_changes, delegated_address) - -def access_delegation( - evm: Evm, address: Address -) -> Tuple[bool, Address, Bytes, Uint]: - """ - Access delegation info and track state changes. - - DEPRECATED: Use check_delegation and apply_delegation_tracking - for proper gas check ordering. - - """ - is_delegated, orig_addr, final_addr, code, gas_cost = check_delegation( - evm, address - ) - - if is_delegated: - apply_delegation_tracking(evm, orig_addr, final_addr) - - return is_delegated, final_addr, code, gas_cost + return get_account(state, delegated_address).code def set_delegation(message: Message) -> U256: diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 389adf6f54..3513e09a58 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -34,8 +34,8 @@ to_address_masked, ) from ...vm.eoa_delegation import ( - apply_delegation_tracking, - check_delegation, + calculate_delegation_cost, + read_delegation_target, ) from .. import ( Evm, @@ -397,13 +397,30 @@ def call(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(to) + # check gas for base access before reading `to` account + base_gas_cost = extend_memory.cost + access_gas_cost + check_gas(evm, base_gas_cost) + + # read `to` account and assess delegation cost ( is_delegated, original_address, - final_address, - code, + delegated_address, delegation_gas_cost, - ) = check_delegation(evm, to) + ) = calculate_delegation_cost(evm, to) + + # check gas again for delegation target access before reading it + if is_delegated and delegation_gas_cost > Uint(0): + check_gas(evm, base_gas_cost + delegation_gas_cost) + + if is_delegated: + assert delegated_address is not None + code = read_delegation_target(evm, delegated_address) + final_address = delegated_address + else: + code = get_account(evm.message.block_env.state, to).code + final_address = to + access_gas_cost += delegation_gas_cost code_address = final_address @@ -421,12 +438,6 @@ def call(evm: Evm) -> None: access_gas_cost + create_gas_cost + transfer_gas_cost, ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - - track_address(evm.state_changes, to) - if is_delegated: - apply_delegation_tracking(evm, original_address, final_address) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) if evm.message.is_static and value != U256(0): raise WriteInStaticContext @@ -497,13 +508,30 @@ def callcode(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(code_address) + # check gas for base access before reading `code_address` account + base_gas_cost = extend_memory.cost + access_gas_cost + check_gas(evm, base_gas_cost) + + # read code_address account and assess delegation cost ( is_delegated, original_address, - final_address, - code, + delegated_address, delegation_gas_cost, - ) = check_delegation(evm, code_address) + ) = calculate_delegation_cost(evm, code_address) + + # check gas again for delegation target access before reading it + if is_delegated and delegation_gas_cost > Uint(0): + check_gas(evm, base_gas_cost + delegation_gas_cost) + + if is_delegated: + assert delegated_address is not None + code = read_delegation_target(evm, delegated_address) + final_address = delegated_address + else: + code = get_account(evm.message.block_env.state, code_address).code + final_address = code_address + access_gas_cost += delegation_gas_cost code_address = final_address @@ -518,12 +546,6 @@ def callcode(evm: Evm) -> None: access_gas_cost + transfer_gas_cost, ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - - track_address(evm.state_changes, original_address) - if is_delegated: - apply_delegation_tracking(evm, original_address, final_address) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -665,16 +687,35 @@ def delegatecall(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + + # check gas for base access before reading `code_address` account + base_gas_cost = extend_memory.cost + access_gas_cost + check_gas(evm, base_gas_cost) + if is_cold_access: evm.accessed_addresses.add(code_address) + # read `code_address` account and assess delegation cost ( is_delegated, original_address, - final_address, - code, + delegated_address, delegation_gas_cost, - ) = check_delegation(evm, code_address) + ) = calculate_delegation_cost(evm, code_address) + + # check gas again for delegation target access before reading it + if is_delegated and delegation_gas_cost > Uint(0): + check_gas(evm, base_gas_cost + delegation_gas_cost) + + # Now safe to read delegation target since we verified gas + if is_delegated: + assert delegated_address is not None + code = read_delegation_target(evm, delegated_address) + final_address = delegated_address + else: + code = get_account(evm.message.block_env.state, code_address).code + final_address = code_address + access_gas_cost += delegation_gas_cost code_address = final_address @@ -684,12 +725,6 @@ def delegatecall(evm: Evm) -> None: U256(0), gas, Uint(evm.gas_left), extend_memory.cost, access_gas_cost ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - - track_address(evm.state_changes, original_address) - if is_delegated: - apply_delegation_tracking(evm, original_address, final_address) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -749,13 +784,31 @@ def staticcall(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(to) + # check gas for base access before reading `to` account + base_gas_cost = extend_memory.cost + access_gas_cost + check_gas(evm, base_gas_cost) + + # read `to` account and assess delegation cost ( is_delegated, original_address, - final_address, - code, + delegated_address, delegation_gas_cost, - ) = check_delegation(evm, to) + ) = calculate_delegation_cost(evm, to) + + # check gas again for delegation target access before reading it + if is_delegated and delegation_gas_cost > Uint(0): + check_gas(evm, base_gas_cost + delegation_gas_cost) + + # Now safe to read delegation target since we verified gas + if is_delegated: + assert delegated_address is not None + code = read_delegation_target(evm, delegated_address) + final_address = delegated_address + else: + code = get_account(evm.message.block_env.state, to).code + final_address = to + access_gas_cost += delegation_gas_cost code_address = final_address @@ -769,12 +822,6 @@ def staticcall(evm: Evm) -> None: access_gas_cost, ) - check_gas(evm, message_call_gas.cost + extend_memory.cost) - - track_address(evm.state_changes, to) - if is_delegated: - apply_delegation_tracking(evm, original_address, final_address) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION diff --git a/tests/prague/eip7702_set_code_tx/test_gas.py b/tests/prague/eip7702_set_code_tx/test_gas.py index 048a84c44b..b415684bf7 100644 --- a/tests/prague/eip7702_set_code_tx/test_gas.py +++ b/tests/prague/eip7702_set_code_tx/test_gas.py @@ -18,6 +18,9 @@ Address, Alloc, AuthorizationTuple, + BalAccountExpectation, + BalNonceChange, + BlockAccessListExpectation, Bytecode, Bytes, ChainConfig, @@ -1269,6 +1272,25 @@ def test_call_to_pre_authorized_oog( sender=pre.fund_eoa(), ) + expected_block_access_list = None + if fork.header_bal_hash_required(): + # Sender nonce changes, callee is accessed but storage unchanged (OOG) + # auth_signer is tracked (we read its code to check delegation) + # delegation is NOT tracked (OOG before reading it) + account_expectations = { + tx.sender: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + callee_address: BalAccountExpectation.empty(), + # read for calculating delegation access cost: + auth_signer: BalAccountExpectation.empty(), + # OOG - not enough gas for delegation access: + delegation: None, + } + expected_block_access_list = BlockAccessListExpectation( + account_expectations=account_expectations + ) + state_test( pre=pre, tx=tx, @@ -1277,4 +1299,5 @@ def test_call_to_pre_authorized_oog( auth_signer: Account(code=Spec.delegation_designation(delegation)), delegation: Account(storage=Storage()), }, + expected_block_access_list=expected_block_access_list, ) From dc01c2f2446533ae1e3d49278953e5053f992170 Mon Sep 17 00:00:00 2001 From: fselmo Date: Sat, 15 Nov 2025 19:22:46 -0300 Subject: [PATCH 26/51] fix(spec-specs): Track code per auth; filter pre at tx frame --- .../amsterdam/block_access_lists/builder.py | 3 +- src/ethereum/forks/amsterdam/state.py | 47 +++++ src/ethereum/forks/amsterdam/state_tracker.py | 10 +- .../forks/amsterdam/vm/eoa_delegation.py | 17 +- .../test_block_access_lists_eip7702.py | 165 ++++++++++++++++++ .../test_cases.md | 4 +- 6 files changed, 238 insertions(+), 8 deletions(-) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index 9af8d2a24c..ae05445b66 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -515,8 +515,7 @@ def build_block_access_list( add_nonce_change(builder, address, block_access_index, new_nonce) # Add all code changes - # Net-zero filtering for code changes should happen at the - # transaction level (in merge_on_success), not at the block level. + # Filtering happens at transaction level in eoa_delegation.py for ( address, block_access_index, diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index d16c9d9d69..c1d331942a 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -655,6 +655,53 @@ def write_code(sender: Account) -> None: track_code_change(state_changes, address, code) +def set_authority_code( + state: State, + address: Address, + code: Bytes, + state_changes: StateChanges, + current_code: Bytes, +) -> None: + """ + Sets authority account code for EIP-7702 delegation. + + This function is used specifically for setting authority code within + EIP-7702 Set Code Transactions. Unlike set_code(), it tracks changes based + on the current code rather than pre_code to handle multiple authorizations + to the same address within a single transaction correctly. + + Parameters + ---------- + state: + The current state. + + address: + Address of the authority account whose code needs to be set. + + code: + The delegation designation bytecode to set. + + state_changes: + State changes frame for tracking (EIP-7928). + + current_code: + The current code before this change. Used to determine if tracking + is needed (only track if code actually changes from current value). + + """ + + def write_code(sender: Account) -> None: + sender.code = code + + modify_state(state, address, write_code) + + # Only track if code is actually changing from current value + # This allows multiple auths to same address to be tracked individually + # Net-zero filtering happens in commit_transaction_frame + if current_code != code: + track_code_change(state_changes, address, code) + + def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: """ Get the original value in a storage slot i.e. the value before the current diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 58ca854bf8..7b98396318 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -372,7 +372,7 @@ def merge_on_success(child_frame: StateChanges) -> None: parent_frame.pre_storage[slot] = value for addr, code in child_frame.pre_code.items(): if addr not in parent_frame.pre_code: - parent_frame.pre_code[addr] = code + capture_pre_code(parent_frame, addr, code) # Merge storage operations, filtering noop writes parent_frame.storage_reads.update(child_frame.storage_reads) @@ -458,9 +458,13 @@ def commit_transaction_frame(tx_frame: StateChanges) -> None: for addr, idx, nonce in tx_frame.nonce_changes: block_frame.nonce_changes.add((addr, idx, nonce)) - # Merge code changes + # Merge code changes - filter net-zero changes within the transaction + # Compare final code against transaction's pre-code for (addr, idx), final_code in tx_frame.code_changes.items(): - block_frame.code_changes[(addr, idx)] = final_code + pre_code = tx_frame.pre_code.get(addr, b"") + if pre_code != final_code: + block_frame.code_changes[(addr, idx)] = final_code + # else: Net-zero change within this transaction - skip def merge_on_failure(child_frame: StateChanges) -> None: diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index 72d6d32215..ec95fd1a47 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -14,7 +14,12 @@ # track_address_access removed - now using state_changes.track_address() from ..fork_types import Address, Authorization -from ..state import account_exists, get_account, increment_nonce, set_code +from ..state import ( + account_exists, + get_account, + increment_nonce, + set_authority_code, +) from ..state_tracker import capture_pre_code, track_address from ..utils.hexadecimal import hex_to_address from ..vm.gas import GAS_COLD_ACCOUNT_ACCESS, GAS_WARM_ACCESS @@ -253,8 +258,16 @@ def set_delegation(message: Message) -> U256: or message.block_env.block_state_changes ) + # Capture pre-code before any changes (first-write-wins) capture_pre_code(state_changes, authority, authority_code) - set_code(state, authority, code_to_set, state_changes) + + # Set delegation code + # Uses authority_code (current) for tracking to handle multiple auths + # Net-zero filtering happens in commit_transaction_frame + set_authority_code( + state, authority, code_to_set, state_changes, authority_code + ) + increment_nonce(state, authority, state_changes) if message.code_address is None: diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py index 2a315b990f..e9bed5df85 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py @@ -663,3 +663,168 @@ def test_bal_7702_null_address_delegation_no_code_change( bob: Account(balance=10), }, ) + + +def test_bal_7702_double_auth_reset( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures the net code change when multiple authorizations + occur in the same transaction (double auth). + + This test verifies that when: + 1. First auth sets delegation to CONTRACT_A + 2. Second auth resets delegation to empty (address 0) + + The BAL should show the NET change (empty -> empty), not intermediate + states. This is a regression test for the bug where the BAL showed + the first auth's code but the final state was empty. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + relayer = pre.fund_eoa() + + contract_a = pre.deploy_contract(code=Op.STOP) + + # Transaction with double auth: + # 1. First sets delegation to contract_a + # 2. Second resets to empty + tx = Transaction( + sender=relayer, + to=bob, + value=10, + gas_limit=1_000_000, + gas_price=0xA, + authorization_list=[ + AuthorizationTuple( + address=contract_a, + nonce=0, + signer=alice, + ), + AuthorizationTuple( + address=0, # Reset to empty + nonce=1, + signer=alice, + ), + ], + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=1, post_nonce=2) + ], + code_changes=[], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=10) + ] + ), + relayer: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=1, post_nonce=1) + ], + ), + contract_a: None, + } + ), + ) + ], + post={ + alice: Account(nonce=2, code=b""), # Final code is empty + bob: Account(balance=10), + relayer: Account(nonce=1), + }, + ) + + +def test_bal_7702_double_auth_swap( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure BAL captures the net code change when double auth swaps + delegation targets. + + This test verifies that when: + 1. First auth sets delegation to CONTRACT_A + 2. Second auth changes delegation to CONTRACT_B + + The BAL should show the final code change (empty -> CONTRACT_B), + not the intermediate CONTRACT_A. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + relayer = pre.fund_eoa() + + contract_a = pre.deploy_contract(code=Op.STOP) + contract_b = pre.deploy_contract(code=Op.STOP) + + tx = Transaction( + sender=relayer, + to=bob, + value=10, + gas_limit=1_000_000, + gas_price=0xA, + authorization_list=[ + AuthorizationTuple( + address=contract_a, + nonce=0, + signer=alice, + ), + AuthorizationTuple( + address=contract_b, # Override to contract_b + nonce=1, + signer=alice, + ), + ], + ) + + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + code_changes=[ + # Should show final code (CONTRACT_B), not CONTRACT_A + BalCodeChange( + tx_index=1, + new_code=Spec7702.delegation_designation(contract_b), + ) + ], + ), + bob: BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + ), + relayer: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + # Neither contract appears in BAL during delegation setup + contract_a: None, + contract_b: None, + } + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account( + nonce=2, code=Spec7702.delegation_designation(contract_b) + ), + bob: Account(balance=10), + relayer: Account(nonce=1), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 5f80be1fbc..39d38a4d27 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -39,7 +39,9 @@ | `test_bal_7702_invalid_nonce_authorization` | Ensure BAL handles failed authorization due to wrong nonce | `Relayer` sends sponsored transaction to Bob (10 wei transfer succeeds) but Alice's authorization to delegate to `Oracle` uses incorrect nonce, causing silent authorization failure | BAL **MUST** include Alice with empty changes (account access), Bob with `balance_changes` (receives 10 wei), Relayer with `nonce_changes`. **MUST NOT** include `Oracle` (authorization failed, no delegation) | ✅ Completed | | `test_bal_7702_invalid_chain_id_authorization` | Ensure BAL handles failed authorization due to wrong chain id | `Relayer` sends sponsored transaction to Bob (10 wei transfer succeeds) but Alice's authorization to delegate to `Oracle` uses incorrect chain id, causing authorization failure before account access | BAL **MUST** include Bob with `balance_changes` (receives 10 wei), Relayer with `nonce_changes`. **MUST NOT** include Alice (authorization fails before loading account) or `Oracle` (authorization failed, no delegation) | ✅ Completed | | `test_bal_7702_delegated_via_call_opcode` | Ensure BAL captures delegation target when a contract uses *CALL opcodes to call a delegated account | Pre-deployed contract `Alice` delegated to `Oracle`. `Caller` contract uses CALL/CALLCODE/DELEGATECALL/STATICCALL to call `Alice`. Bob sends transaction to `Caller`. | BAL **MUST** include Bob: `nonce_changes`. `Caller`: empty changes (account access). `Alice`: empty changes (account access - delegated account being called). `Oracle`: empty changes (delegation target access). | ✅ Completed | -| `test_bal_7702_null_address_delegation` | Ensure BAL does not record spurious code changes for net-zero code operations | Alice sends transaction with authorization delegating to NULL_ADDRESS (0x0), which sets code to `b""` on an account that already has `b""` code. Transaction sends 10 wei to Bob. | BAL **MUST** include Alice with `nonce_changes` (tx nonce + auth nonce increment) but **MUST NOT** include `code_changes` (setting `b"" -> b""` is net-zero and filtered out). Bob: `balance_changes` (receives 10 wei). This ensures net-zero code change is not recorded. +| `test_bal_7702_null_address_delegation` | Ensure BAL does not record spurious code changes for net-zero code operations | Alice sends transaction with authorization delegating to NULL_ADDRESS (0x0), which sets code to `b""` on an account that already has `b""` code. Transaction sends 10 wei to Bob. | BAL **MUST** include Alice with `nonce_changes` (tx nonce + auth nonce increment) but **MUST NOT** include `code_changes` (setting `b"" -> b""` is net-zero and filtered out). Bob: `balance_changes` (receives 10 wei). This ensures net-zero code change is not recorded. | ✅ Completed | +| `test_bal_7702_double_auth_reset` | Ensure BAL captures net code change when double auth resets delegation | `Relayer` sends transaction with two authorizations for Alice: (1) First auth sets delegation to `CONTRACT_A` at nonce=0, (2) Second auth resets delegation to empty (address 0) at nonce=1. Transaction sends 10 wei to Bob. Per EIP-7702, only the last authorization takes effect. | BAL **MUST** include Alice with `nonce_changes` (both auths increment nonce to 2) but **MUST NOT** include `code_changes` (net change is empty → empty). Bob: `balance_changes` (receives 10 wei). Relayer: `nonce_changes`. `CONTRACT_A` **MUST NOT** be in BAL (never accessed). This is a regression test for the bug where BAL showed first auth's code despite final state being empty. | ✅ Completed | +| `test_bal_7702_double_auth_swap` | Ensure BAL captures final code when double auth swaps delegation targets | `Relayer` sends transaction with two authorizations for Alice: (1) First auth sets delegation to `CONTRACT_A` at nonce=0, (2) Second auth changes delegation to `CONTRACT_B` at nonce=1. Transaction sends 10 wei to Bob. Per EIP-7702, only the last authorization takes effect. | BAL **MUST** include Alice with `nonce_changes` (both auths increment nonce to 2) and `code_changes` (final code is delegation designation for `CONTRACT_B`, not `CONTRACT_A`). Bob: `balance_changes` (receives 10 wei). Relayer: `nonce_changes`. Neither `CONTRACT_A` nor `CONTRACT_B` appear in BAL during delegation setup (never accessed). This ensures BAL shows final state, not intermediate changes. | ✅ Completed | | `test_bal_sstore_and_oog` | Ensure BAL handles OOG during SSTORE execution at various gas boundaries (EIP-2200 stipend and implicit SLOAD) | Alice calls contract that attempts `SSTORE` to cold slot `0x01`. Parameterized: (1) OOG at EIP-2200 stipend check (2300 gas after PUSH opcodes) - fails before implicit SLOAD, (2) OOG at stipend + 1 (2301 gas) - passes stipend check but fails after implicit SLOAD, (3) OOG at exact gas - 1, (4) Successful SSTORE with exact gas. | For case (1): BAL **MUST NOT** include slot `0x01` in `storage_reads` or `storage_changes` (fails before implicit SLOAD). For cases (2) and (3): BAL **MUST** include slot `0x01` in `storage_reads` (implicit SLOAD occurred) but **MUST NOT** include in `storage_changes` (write didn't complete). For case (4): BAL **MUST** include slot `0x01` in `storage_changes` only (successful write; read is filtered by builder). | ✅ Completed | | `test_bal_sstore_static_context` | Ensure BAL does not capture spurious storage access when SSTORE fails in static context | Alice calls contract with `STATICCALL` which attempts `SSTORE` to slot `0x01`. SSTORE must fail before any storage access occurs. | BAL **MUST NOT** include slot `0x01` in `storage_reads` or `storage_changes`. Static context check happens before storage access, preventing spurious reads. Alice has `nonce_changes` and `balance_changes` (gas cost). Target contract included with empty changes. | ✅ Completed | | `test_bal_sload_and_oog` | Ensure BAL handles OOG during SLOAD execution correctly | Alice calls contract that attempts `SLOAD` from cold slot `0x01`. Parameterized: (1) OOG at SLOAD opcode (insufficient gas), (2) Successful SLOAD execution. | For OOG case: BAL **MUST NOT** contain slot `0x01` in `storage_reads` since storage wasn't accessed. For success case: BAL **MUST** contain slot `0x01` in `storage_reads`. | ✅ Completed | From 7f424779ee30e5ea765c4961338a12db74df14c5 Mon Sep 17 00:00:00 2001 From: fselmo Date: Sun, 16 Nov 2025 18:05:39 -0300 Subject: [PATCH 27/51] fix(spec-specs): Use proper frames for system transactions --- .../amsterdam/block_access_lists/builder.py | 11 +- src/ethereum/forks/amsterdam/fork.py | 10 + .../test_block_access_lists_cross_index.py | 260 ++++++++++++++++++ 3 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index ae05445b66..c1dbf98222 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -484,19 +484,14 @@ def build_block_access_list( for address, slot in state_changes.storage_reads: add_storage_read(builder, address, slot) - # Add all storage writes, filtering net-zero changes + # Add all storage writes + # Net-zero filtering happens at transaction commit time, not here. + # At block level, we track ALL writes at their respective indices. for ( address, slot, block_access_index, ), value in state_changes.storage_writes.items(): - # Check if this is a net-zero change by comparing with pre-state - if (address, slot) in state_changes.pre_storage: - if state_changes.pre_storage[(address, slot)] == value: - # Net-zero change - convert to read only - add_storage_read(builder, address, slot) - continue - # Convert U256 to Bytes32 for storage value_bytes = Bytes32(value.to_bytes(U256(32), "big")) add_storage_write( diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 9abb065f0e..f5a7081fd9 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -71,6 +71,7 @@ get_block_access_index, handle_in_transaction_selfdestruct, increment_block_access_index, + merge_on_success, normalize_balance_changes_for_transaction, track_address, track_balance_change, @@ -633,6 +634,10 @@ def process_system_transaction( Output of processing the system transaction. """ + # EIP-7928: Create a child frame for system transaction + # This allows proper pre-state capture for net-zero filtering + system_tx_state_changes = create_child_frame(block_env.block_state_changes) + tx_env = vm.TransactionEnvironment( origin=SYSTEM_ADDRESS, gas_price=block_env.base_fee_per_gas, @@ -664,10 +669,15 @@ def process_system_transaction( accessed_storage_keys=set(), disable_precompiles=False, parent_evm=None, + transaction_state_changes=system_tx_state_changes, ) system_tx_output = process_message_call(system_tx_message) + # Merge system transaction changes back to block frame + # System transactions always succeed (or block is invalid) + merge_on_success(system_tx_state_changes) + return system_tx_output diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py new file mode 100644 index 0000000000..920b8bc344 --- /dev/null +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py @@ -0,0 +1,260 @@ +""" +Tests for EIP-7928 BAL cross-index tracking. + +Tests that state changes are correctly tracked across different block indices: +- Index 1..N: Regular transactions +- Index N+1: Post-execution system operations + +Includes tests for system contracts (withdrawal/consolidation) cross-index +tracking and NOOP filtering behavior. +""" + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Bytecode, + Op, + Transaction, +) + +from .spec import ref_spec_7928 + +REFERENCE_SPEC_GIT_PATH = ref_spec_7928.git_path +REFERENCE_SPEC_VERSION = ref_spec_7928.version + +pytestmark = pytest.mark.valid_from("Amsterdam") + +WITHDRAWAL_REQUEST_ADDRESS = Address( + 0x00000961EF480EB55E80D19AD83579A64C007002 +) +CONSOLIDATION_REQUEST_ADDRESS = Address( + 0x0000BBDDC7CE488642FB579F8B00F3A590007251 +) + + +def test_bal_withdrawal_contract_cross_index( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test that the withdrawal system contract shows storage changes at both + index 1 (during transaction) and index 2 (during post-execution). + + This verifies that slots 0x01 and 0x03 are: + 1. Incremented during the transaction (index 1) + 2. Reset during post-execution (index 2) + """ + sender = pre.fund_eoa() + + withdrawal_calldata = ( + (b"\x01" + b"\x00" * 47) # validator pubkey + + (b"\x00" * 8) # amount + ) + + tx = Transaction( + sender=sender, + to=WITHDRAWAL_REQUEST_ADDRESS, + value=1, + data=withdrawal_calldata, + gas_limit=1_000_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + WITHDRAWAL_REQUEST_ADDRESS: BalAccountExpectation( + # slots 0x01 and 0x03 change at BOTH indices + storage_changes=[ + BalStorageSlot( + slot=0x01, # Request count + slot_changes=[ + BalStorageChange( + # Incremented during tx + tx_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + tx_index=2, + post_value=0, + ), + ], + ), + BalStorageSlot( + slot=0x03, # Target count + slot_changes=[ + BalStorageChange( + # Incremented during tx + tx_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + tx_index=2, + post_value=0, + ), + ], + ), + ], + ), + } + ), + ) + ], + post={}, + ) + + +def test_bal_consolidation_contract_cross_index( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test that the consolidation system contract shows storage changes at both + index 1 (during transaction) and index 2 (during post-execution). + """ + sender = pre.fund_eoa() + + consolidation_calldata = ( + (b"\x01" + b"\x00" * 47) # source pubkey + + (b"\x02" + b"\x00" * 47) # target pubkey + ) + + tx = Transaction( + sender=sender, + to=CONSOLIDATION_REQUEST_ADDRESS, + value=1, + data=consolidation_calldata, + gas_limit=1_000_000, + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + CONSOLIDATION_REQUEST_ADDRESS: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange( + # Incremented during tx + tx_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + tx_index=2, + post_value=0, + ), + ], + ), + BalStorageSlot( + slot=0x03, + slot_changes=[ + BalStorageChange( + # Incremented during tx + tx_index=1, + post_value=1, + ), + BalStorageChange( + # Reset during post-exec + tx_index=2, + post_value=0, + ), + ], + ), + ], + ), + } + ), + ) + ], + post={}, + ) + + +def test_bal_noop_write_filtering( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test that NOOP writes (writing same value or 0 to empty) are filtered. + + This verifies that: + 1. Writing 0 to an uninitialized slot doesn't appear in BAL + 2. Writing the same value to a slot doesn't appear in BAL + 3. Only actual changes are tracked + """ + test_code = Bytecode( + # Write 0 to uninitialized slot 1 (noop) + Op.SSTORE(1, 0) + # Write 42 to slot 2 + + Op.SSTORE(2, 42) + # Write 100 to slot 3 (will be same as pre-state, should be filtered) + + Op.SSTORE(3, 100) + # Write 200 to slot 4 (different from pre-state 150, should appear) + + Op.SSTORE(4, 200) + ) + + sender = pre.fund_eoa() + test_address = pre.deploy_contract( + code=test_code, + storage={3: 100, 4: 150}, + ) + + tx = Transaction( + sender=sender, + to=test_address, + gas_limit=100_000, + ) + + # Expected BAL should only show actual changes + expected_block_access_list = BlockAccessListExpectation( + account_expectations={ + test_address: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=2, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=42), + ], + ), + BalStorageSlot( + slot=4, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=200), + ], + ), + ], + ), + } + ) + + block = Block( + txs=[tx], + expected_block_access_list=expected_block_access_list, + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + test_address: Account(storage={2: 42, 3: 100, 4: 200}), + }, + ) From 6ece1e5150ff8a96c2c57602ea79a51a70c8ce72 Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 20 Nov 2025 22:58:11 -0300 Subject: [PATCH 28/51] fix(spec-specs): Track address at init collision --- src/ethereum/forks/amsterdam/vm/interpreter.py | 3 +++ .../eip7610_create_collision/test_initcollision.py | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index a63b745624..57f890a12e 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -173,6 +173,9 @@ def process_message_call(message: Message) -> MessageCallOutput: is_collision = account_has_code_or_nonce( block_env.state, message.current_target ) or account_has_storage(block_env.state, message.current_target) + track_address( + message.transaction_state_changes, message.current_target + ) if is_collision: return MessageCallOutput( Uint(0), diff --git a/tests/paris/eip7610_create_collision/test_initcollision.py b/tests/paris/eip7610_create_collision/test_initcollision.py index 181b4575b1..b8382f634d 100644 --- a/tests/paris/eip7610_create_collision/test_initcollision.py +++ b/tests/paris/eip7610_create_collision/test_initcollision.py @@ -7,7 +7,10 @@ from execution_testing import ( Account, Alloc, + BalAccountExpectation, + BlockAccessListExpectation, Bytecode, + Fork, Initcode, Op, StateTestFiller, @@ -66,6 +69,7 @@ def test_init_collision_create_tx( collision_balance: int, collision_code: bytes, initcode: Bytecode, + fork: Fork, ) -> None: """ Test that a contract creation transaction exceptionally aborts when @@ -89,6 +93,14 @@ def test_init_collision_create_tx( code=collision_code, ) + expected_block_access_list = None + if fork.header_bal_hash_required(): + expected_block_access_list = BlockAccessListExpectation( + account_expectations={ + created_contract_address: BalAccountExpectation.empty() + } + ) + state_test( pre=pre, post={ @@ -97,6 +109,7 @@ def test_init_collision_create_tx( ), }, tx=tx, + expected_block_access_list=expected_block_access_list, ) From 6c1d971a7b922a0efc92a84fdbfb53eca5830a17 Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 20 Nov 2025 22:59:11 -0300 Subject: [PATCH 29/51] chore(spec-specs): Add Amsterdam docstring; update prepare msg --- src/ethereum/forks/amsterdam/__init__.py | 12 +++++++++--- src/ethereum/forks/amsterdam/fork.py | 9 ++++++--- src/ethereum/forks/amsterdam/utils/message.py | 5 +++++ src/ethereum/forks/amsterdam/vm/__init__.py | 2 +- .../forks/amsterdam/vm/instructions/system.py | 1 + 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/ethereum/forks/amsterdam/__init__.py b/src/ethereum/forks/amsterdam/__init__.py index de18ac5d18..1e653dbb9d 100644 --- a/src/ethereum/forks/amsterdam/__init__.py +++ b/src/ethereum/forks/amsterdam/__init__.py @@ -1,8 +1,14 @@ """ -The Amsterdam fork. +The Amsterdam fork ([EIP-7773]) includes block-level access lists. -TODO: Update with information for included EIPs as other forks do. -""" +### Changes + +- [EIP-7928: Block-Level Access Lists][EIP-7928] + +### Releases + +[EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 +""" # noqa: E501 from ethereum.fork_criteria import ForkCriteria, Unscheduled diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index f5a7081fd9..358f194f6e 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -995,9 +995,12 @@ def process_transaction( tx_hash=get_transaction_hash(encode_transaction(tx)), ) - message = prepare_message(block_env, tx_env, tx) - # Set transaction frame so call frames become children of it - message.transaction_state_changes = tx_state_changes + message = prepare_message( + block_env, + tx_env, + tx, + tx_state_changes, + ) tx_output = process_message_call(message) diff --git a/src/ethereum/forks/amsterdam/utils/message.py b/src/ethereum/forks/amsterdam/utils/message.py index 107cdcaf7a..def5b36e20 100644 --- a/src/ethereum/forks/amsterdam/utils/message.py +++ b/src/ethereum/forks/amsterdam/utils/message.py @@ -17,6 +17,7 @@ from ..fork_types import Address from ..state import get_account +from ..state_tracker import StateChanges from ..transactions import Transaction from ..vm import BlockEnvironment, Message, TransactionEnvironment from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS @@ -27,6 +28,7 @@ def prepare_message( block_env: BlockEnvironment, tx_env: TransactionEnvironment, tx: Transaction, + transaction_state_changes: StateChanges, ) -> Message: """ Execute a transaction against the provided environment. @@ -39,6 +41,8 @@ def prepare_message( Environment for the transaction. tx : Transaction to be executed. + transaction_state_changes : + State changes specific to this transaction. Returns ------- @@ -87,4 +91,5 @@ def prepare_message( accessed_storage_keys=set(tx_env.access_list_storage_keys), disable_precompiles=False, parent_evm=None, + transaction_state_changes=transaction_state_changes, ) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 26b7e99e45..d414aa50f9 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -142,7 +142,7 @@ class Message: accessed_storage_keys: Set[Tuple[Address, Bytes32]] disable_precompiles: bool parent_evm: Optional["Evm"] - transaction_state_changes: Optional[StateChanges] = None + transaction_state_changes: StateChanges @dataclass diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 3513e09a58..67666014e2 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -144,6 +144,7 @@ def generic_create( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=False, parent_evm=evm, + transaction_state_changes=evm.message.transaction_state_changes, ) child_evm = process_create_message(child_message) From 775cb9a17dbf9c1bcb1a975893acc73225136d7b Mon Sep 17 00:00:00 2001 From: fselmo Date: Thu, 20 Nov 2025 23:30:18 -0300 Subject: [PATCH 30/51] chore: Add pre-amsterdam BAL tests to doc for tracking --- .../amsterdam/eip7928_block_level_access_lists/test_cases.md | 3 +++ .../cancun/eip6780_selfdestruct/test_selfdestruct_revert.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 39d38a4d27..be92e54608 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -90,3 +90,6 @@ | `test_bal_storage_write_read_same_frame` | Ensure BAL captures write precedence over read in same call frame (writes shadow reads) | Alice calls `Oracle` which writes (`SSTORE`) value `0x42` to slot `0x01`, then reads (`SLOAD`) from slot `0x01` in the same call frame | BAL **MUST** include `Oracle` with slot `0x01` in `storage_changes` showing final value `0x42`. Slot `0x01` **MUST NOT** appear in `storage_reads` (write shadows the subsequent read in same frame). | ✅ Completed | | `test_bal_storage_write_read_cross_frame` | Ensure BAL captures write precedence over read across call frames (writes shadow reads cross-frame) | Alice calls `Oracle`. First call reads slot `0x01` (sees initial value), writes `0x42` to slot `0x01`, then calls itself (via `CALL`, `DELEGATECALL`, or `CALLCODE`). Second call reads slot `0x01` (sees `0x42`) and exits. | BAL **MUST** include `Oracle` with slot `0x01` in `storage_changes` showing final value `0x42`. Slot `0x01` **MUST NOT** appear in `storage_reads` (write shadows both the read before it in same frame and the read in the recursive call). | ✅ Completed | | `test_bal_create_transaction_empty_code` | Ensure BAL does not record spurious code changes for CREATE transaction deploying empty code | Alice sends CREATE transaction with empty initcode (deploys code `b""`). Contract address gets nonce = 1 and code = `b""`. | BAL **MUST** include Alice with `nonce_changes` and created contract with `nonce_changes` but **MUST NOT** include `code_changes` for contract. | ✅ Completed | +| `test_init_collision_create_tx` | Ensure BAL tracks CREATE collisions correctly (pre-Amsterdam test with BAL) | CREATE transaction targeting address with existing storage aborts | BAL **MUST** show empty expectations for collision address (no changes occur due to abort) | ✅ Completed | +| `test_call_to_pre_authorized_oog` | Ensure BAL handles OOG during EIP-7702 delegation access (pre-Amsterdam test with BAL) | Call to delegated account that OOGs before accessing delegation contract | BAL **MUST** include auth_signer (code read for delegation check) but **MUST NOT** include delegation contract (OOG before access) | ✅ Completed | +| `test_selfdestruct_created_in_same_tx_with_revert` | Ensure BAL tracks selfdestruct with revert correctly (pre-Amsterdam test with BAL) | Contract created and selfdestructed in same tx with nested revert | BAL **MUST** track storage reads and balance changes for selfdestruct even with reverts | ✅ Completed | diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py index 2c7bbaea3e..59812174ef 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py @@ -441,7 +441,10 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 selfdestruct_with_transfer_contract_address ] = BalAccountExpectation( storage_reads=[0, 1], # Storage was accessed - balance_changes=[], # No net balance change + nonce_changes=[], + balance_changes=[], + code_changes=[], + storage_changes=[], ) account_expectations[selfdestruct_recipient_address] = ( BalAccountExpectation( From 635edd8d32f33fcbd5738e14947dfcd114184be9 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 24 Nov 2025 13:50:04 -0700 Subject: [PATCH 31/51] fix(spec-specs): Calculate all gas we can before accessing state - Calculate all gas that we can without state access and check this gas before ever accessing state. This is the most sensible way for an implementation to behave and indeed was revealed to be the way clients are behaving, which differed from the specs. - Use fork.gas_costs to calculate gas costs, NOT hard-coded values. - Create a BAL expectation for the test that yielded discrepancies between clients and specs so this doesn't slip through again. Document this test in `test_cases.md`. --- .../forks/amsterdam/vm/instructions/system.py | 171 ++++++++++-------- .../test_cases.md | 1 + .../test_call_and_callcode_gas_calculation.py | 104 +++++++++-- 3 files changed, 187 insertions(+), 89 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 67666014e2..1fca8b1459 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -395,14 +395,22 @@ def call(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) + + transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE + + check_gas( + evm, + access_gas_cost + transfer_gas_cost + extend_memory.cost, + ) + + # need to access account to check if account is alive, check gas before + create_gas_cost = GAS_NEW_ACCOUNT + if value == 0 or is_account_alive(evm.message.block_env.state, to): + create_gas_cost = Uint(0) + if is_cold_access: evm.accessed_addresses.add(to) - # check gas for base access before reading `to` account - base_gas_cost = extend_memory.cost + access_gas_cost - check_gas(evm, base_gas_cost) - - # read `to` account and assess delegation cost ( is_delegated, original_address, @@ -410,35 +418,36 @@ def call(evm: Evm) -> None: delegation_gas_cost, ) = calculate_delegation_cost(evm, to) - # check gas again for delegation target access before reading it if is_delegated and delegation_gas_cost > Uint(0): - check_gas(evm, base_gas_cost + delegation_gas_cost) - - if is_delegated: assert delegated_address is not None + message_call_gas = calculate_message_call_gas( + value, + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + + transfer_gas_cost + + create_gas_cost + + delegation_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = read_delegation_target(evm, delegated_address) final_address = delegated_address else: + message_call_gas = calculate_message_call_gas( + value, + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + create_gas_cost + transfer_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = get_account(evm.message.block_env.state, to).code final_address = to - access_gas_cost += delegation_gas_cost - code_address = final_address disable_precompiles = is_delegated - create_gas_cost = GAS_NEW_ACCOUNT - if value == 0 or is_account_alive(evm.message.block_env.state, to): - create_gas_cost = Uint(0) - transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE - message_call_gas = calculate_message_call_gas( - value, - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost + create_gas_cost + transfer_gas_cost, - ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) if evm.message.is_static and value != U256(0): raise WriteInStaticContext @@ -509,11 +518,14 @@ def callcode(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(code_address) - # check gas for base access before reading `code_address` account - base_gas_cost = extend_memory.cost + access_gas_cost - check_gas(evm, base_gas_cost) + transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE + + check_gas( + evm, + access_gas_cost + extend_memory.cost + transfer_gas_cost, + ) - # read code_address account and assess delegation cost + # need to access account to get delegation code, check gas before ( is_delegated, original_address, @@ -521,32 +533,34 @@ def callcode(evm: Evm) -> None: delegation_gas_cost, ) = calculate_delegation_cost(evm, code_address) - # check gas again for delegation target access before reading it if is_delegated and delegation_gas_cost > Uint(0): - check_gas(evm, base_gas_cost + delegation_gas_cost) - - if is_delegated: assert delegated_address is not None + # Recalculate with delegation cost and check gas + message_call_gas = calculate_message_call_gas( + value, + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + transfer_gas_cost + delegation_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = read_delegation_target(evm, delegated_address) final_address = delegated_address else: + message_call_gas = calculate_message_call_gas( + value, + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + transfer_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = get_account(evm.message.block_env.state, code_address).code final_address = code_address - access_gas_cost += delegation_gas_cost - code_address = final_address disable_precompiles = is_delegated - transfer_gas_cost = Uint(0) if value == 0 else GAS_CALL_VALUE - message_call_gas = calculate_message_call_gas( - value, - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost + transfer_gas_cost, - ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -688,15 +702,12 @@ def delegatecall(evm: Evm) -> None: access_gas_cost = ( GAS_COLD_ACCOUNT_ACCESS if is_cold_access else GAS_WARM_ACCESS ) - - # check gas for base access before reading `code_address` account - base_gas_cost = extend_memory.cost + access_gas_cost - check_gas(evm, base_gas_cost) - if is_cold_access: evm.accessed_addresses.add(code_address) - # read `code_address` account and assess delegation cost + check_gas(evm, access_gas_cost + extend_memory.cost) + + # need to access account to get delegation code, check gas before ( is_delegated, original_address, @@ -704,28 +715,33 @@ def delegatecall(evm: Evm) -> None: delegation_gas_cost, ) = calculate_delegation_cost(evm, code_address) - # check gas again for delegation target access before reading it if is_delegated and delegation_gas_cost > Uint(0): - check_gas(evm, base_gas_cost + delegation_gas_cost) - - # Now safe to read delegation target since we verified gas - if is_delegated: assert delegated_address is not None + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + delegation_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = read_delegation_target(evm, delegated_address) final_address = delegated_address else: + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = get_account(evm.message.block_env.state, code_address).code final_address = code_address - access_gas_cost += delegation_gas_cost - code_address = final_address disable_precompiles = is_delegated - message_call_gas = calculate_message_call_gas( - U256(0), gas, Uint(evm.gas_left), extend_memory.cost, access_gas_cost - ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION @@ -785,11 +801,9 @@ def staticcall(evm: Evm) -> None: if is_cold_access: evm.accessed_addresses.add(to) - # check gas for base access before reading `to` account - base_gas_cost = extend_memory.cost + access_gas_cost - check_gas(evm, base_gas_cost) + check_gas(evm, access_gas_cost + extend_memory.cost) - # read `to` account and assess delegation cost + # need to access account to get delegation code, check gas before ( is_delegated, original_address, @@ -797,32 +811,33 @@ def staticcall(evm: Evm) -> None: delegation_gas_cost, ) = calculate_delegation_cost(evm, to) - # check gas again for delegation target access before reading it if is_delegated and delegation_gas_cost > Uint(0): - check_gas(evm, base_gas_cost + delegation_gas_cost) - - # Now safe to read delegation target since we verified gas - if is_delegated: assert delegated_address is not None + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost + delegation_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = read_delegation_target(evm, delegated_address) final_address = delegated_address else: + message_call_gas = calculate_message_call_gas( + U256(0), + gas, + Uint(evm.gas_left), + extend_memory.cost, + access_gas_cost, + ) + check_gas(evm, message_call_gas.cost + extend_memory.cost) code = get_account(evm.message.block_env.state, to).code final_address = to - access_gas_cost += delegation_gas_cost - code_address = final_address disable_precompiles = is_delegated - message_call_gas = calculate_message_call_gas( - U256(0), - gas, - Uint(evm.gas_left), - extend_memory.cost, - access_gas_cost, - ) - charge_gas(evm, message_call_gas.cost + extend_memory.cost) # OPERATION diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index be92e54608..fd843a4123 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -93,3 +93,4 @@ | `test_init_collision_create_tx` | Ensure BAL tracks CREATE collisions correctly (pre-Amsterdam test with BAL) | CREATE transaction targeting address with existing storage aborts | BAL **MUST** show empty expectations for collision address (no changes occur due to abort) | ✅ Completed | | `test_call_to_pre_authorized_oog` | Ensure BAL handles OOG during EIP-7702 delegation access (pre-Amsterdam test with BAL) | Call to delegated account that OOGs before accessing delegation contract | BAL **MUST** include auth_signer (code read for delegation check) but **MUST NOT** include delegation contract (OOG before access) | ✅ Completed | | `test_selfdestruct_created_in_same_tx_with_revert` | Ensure BAL tracks selfdestruct with revert correctly (pre-Amsterdam test with BAL) | Contract created and selfdestructed in same tx with nested revert | BAL **MUST** track storage reads and balance changes for selfdestruct even with reverts | ✅ Completed | +| `test_value_transfer_gas_calculation` | Ensure BAL correctly tracks OOG scenarios for CALL/CALLCODE/DELEGATECALL/STATICCALL (pre-Amsterdam test with BAL) | Nested calls with precise gas limits to test OOG behavior. For CALL with OOG: target account read for `is_account_alive` check. For CALLCODE/DELEGATECALL/STATICCALL with OOG: target account **NOT** read (OOG before state access) | For CALL: target in BAL even with OOG. For CALLCODE/DELEGATECALL/STATICCALL: target **NOT** in BAL when OOG (state access deferred until after gas check) | ✅ Completed | diff --git a/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py b/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py index abc9d9a28c..015217521d 100644 --- a/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py +++ b/tests/frontier/opcodes/test_call_and_callcode_gas_calculation.py @@ -40,6 +40,11 @@ Account, Address, Alloc, + BalAccountExpectation, + BalBalanceChange, + BalStorageChange, + BalStorageSlot, + BlockAccessListExpectation, Bytecode, Environment, Op, @@ -71,33 +76,42 @@ def sufficient_gas( Calculate the sufficient gas for the nested call opcode with positive value transfer. """ - # memory_exp_cost is zero for our case. + gas_costs = fork.gas_costs() + cost = 0 if fork >= Berlin: - cost += 2600 # call and address_access_cost + cost += gas_costs.G_COLD_ACCOUNT_ACCESS elif Byzantium <= fork < Berlin: - cost += 700 # call + cost += 700 # Pre-Berlin warm call cost elif fork == Homestead: - cost += 40 # call + cost += 40 # Homestead call cost cost += 1 # mandatory callee gas allowance else: raise Exception("Only forks Homestead and >=Byzantium supported") is_value_call = callee_opcode in [Op.CALL, Op.CALLCODE] if is_value_call: - cost += 9000 # positive_value_cost + cost += gas_costs.G_CALL_VALUE if callee_opcode == Op.CALL: - cost += 25000 # empty_account_cost + cost += gas_costs.G_NEW_ACCOUNT - cost += callee_init_stack_gas + sufficient = callee_init_stack_gas + cost - return cost + return sufficient @pytest.fixture -def callee_code(pre: Alloc, callee_opcode: Op, fork: Fork) -> Bytecode: +def empty_account(pre: Alloc) -> Address: + """A guaranteed-to-be-empty account.""" + return pre.empty_account() + + +@pytest.fixture +def callee_code( + callee_opcode: Op, fork: Fork, empty_account: Address +) -> Bytecode: """ Code called by the caller contract: PUSH1 0x00 * 4 @@ -119,7 +133,7 @@ def callee_code(pre: Alloc, callee_opcode: Op, fork: Fork) -> Bytecode: return callee_opcode( unchecked=False, gas=1 if fork < Byzantium else Op.GAS, - address=pre.empty_account(), + address=empty_account, args_offset=0, args_size=0, ret_offset=0, @@ -197,6 +211,67 @@ def post( # noqa: D103 } +@pytest.fixture +def expected_block_access_list( + fork: Fork, + caller_address: Address, + callee_address: Address, + callee_opcode: Bytecode, + empty_account: Account, + gas_shortage: int, +) -> None | BlockAccessListExpectation: + """The expected block access list for >=Amsterdam cases.""" + if fork.header_bal_hash_required(): + if callee_opcode == Op.CALL: + if gas_shortage: + # call runs OOG after state access due to `is_account_alive` in + # `create_gas_cost` check + empty_account_expectation = BalAccountExpectation.empty() + else: + empty_account_expectation = BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=1) + ] + ) + else: + if gas_shortage: + # runs OOG before accessing empty acct (not read) + empty_account_expectation = None + else: + # if successful, only read is recorded + empty_account_expectation = BalAccountExpectation.empty() + + return BlockAccessListExpectation( + account_expectations={ + empty_account: empty_account_expectation, + caller_address: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=4) + ], + storage_reads=[0] if gas_shortage else [], + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=1), + ], + ), + ] + if not gas_shortage + else [], + ), + callee_address: BalAccountExpectation( + balance_changes=( + [BalBalanceChange(tx_index=1, post_balance=2)] + if not gas_shortage and callee_opcode == Op.CALL + else [] + ), + ), + } + ) + return None + + @pytest.mark.parametrize( "callee_opcode", [Op.CALL, Op.CALLCODE, Op.DELEGATECALL, Op.STATICCALL] ) @@ -207,12 +282,19 @@ def test_value_transfer_gas_calculation( pre: Alloc, caller_tx: Transaction, post: Dict[str, Account], + expected_block_access_list: BlockAccessListExpectation, ) -> None: """ Tests the nested CALL/CALLCODE/DELEGATECALL/STATICCALL opcode gas consumption with a positive value transfer. """ - state_test(env=Environment(), pre=pre, post=post, tx=caller_tx) + state_test( + env=Environment(), + pre=pre, + post=post, + tx=caller_tx, + expected_block_access_list=expected_block_access_list, + ) @pytest.mark.parametrize( From 39ed8c321aa08403405b9affa0fb285e09448032 Mon Sep 17 00:00:00 2001 From: felipe Date: Tue, 25 Nov 2025 20:36:27 +0000 Subject: [PATCH 32/51] fix(test-tools): Remove named forks from blobSchedule; turn off BPOs --- .../plugins/consume/simulators/helpers/ruleset.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py index 0561727b0c..15ab56bc9c 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py @@ -296,7 +296,6 @@ def get_blob_schedule_entries(fork: Fork) -> Dict[str, int]: "HIVE_CANCUN_TIMESTAMP": 0, "HIVE_PRAGUE_TIMESTAMP": 0, "HIVE_OSAKA_TIMESTAMP": 0, - **get_blob_schedule_entries(Osaka), }, PragueToOsakaAtTime15k: { "HIVE_FORK_HOMESTEAD": 0, @@ -314,7 +313,6 @@ def get_blob_schedule_entries(fork: Fork) -> Dict[str, int]: "HIVE_CANCUN_TIMESTAMP": 0, "HIVE_PRAGUE_TIMESTAMP": 0, "HIVE_OSAKA_TIMESTAMP": 15000, - **get_blob_schedule_entries(Osaka), }, BPO1: { "HIVE_FORK_HOMESTEAD": 0, @@ -496,11 +494,10 @@ def get_blob_schedule_entries(fork: Fork) -> Dict[str, int]: "HIVE_CANCUN_TIMESTAMP": 0, "HIVE_PRAGUE_TIMESTAMP": 0, "HIVE_OSAKA_TIMESTAMP": 0, - "HIVE_BPO1_TIMESTAMP": 0, - "HIVE_BPO2_TIMESTAMP": 0, - "HIVE_BPO3_TIMESTAMP": 0, - "HIVE_BPO4_TIMESTAMP": 0, + # TODO: While we are still reworking BPO interaction with T8N, + # turn off BPO timestamps for now. + # "HIVE_BPO1_TIMESTAMP": 0, + # "HIVE_BPO2_TIMESTAMP": 0, "HIVE_AMSTERDAM_TIMESTAMP": 0, - **get_blob_schedule_entries(Amsterdam), }, } From 2b6e4d6cc15400b084b3f6bf4bd1b584823baa23 Mon Sep 17 00:00:00 2001 From: felix Date: Wed, 26 Nov 2025 10:24:46 +0000 Subject: [PATCH 33/51] fix: add bal exception for erigon (#1809) --- .../testing/src/execution_testing/client_clis/clis/erigon.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/testing/src/execution_testing/client_clis/clis/erigon.py b/packages/testing/src/execution_testing/client_clis/clis/erigon.py index 3ac873f1d1..9d25027121 100644 --- a/packages/testing/src/execution_testing/client_clis/clis/erigon.py +++ b/packages/testing/src/execution_testing/client_clis/clis/erigon.py @@ -56,6 +56,7 @@ class ErigonExceptionMapper(ExceptionMapper): BlockException.INVALID_LOG_BLOOM: "invalid bloom", } mapping_regex = { + BlockException.INVALID_BLOCK_ACCESS_LIST: r"invalid block access list|block access list mismatch", TransactionException.GAS_LIMIT_EXCEEDS_MAXIMUM: ( r"invalid block, txnIdx=\d+,.*gas limit too high" ), From 3d65d6fda8afcfb26211823213b10eb2b2fcf7d0 Mon Sep 17 00:00:00 2001 From: fselmo Date: Wed, 26 Nov 2025 09:46:27 -0700 Subject: [PATCH 34/51] feat(test): Better describe the BAL for selfdestruct revert --- .../test_selfdestruct_revert.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py index 59812174ef..ab2d22f112 100644 --- a/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py +++ b/tests/cancun/eip6780_selfdestruct/test_selfdestruct_revert.py @@ -10,6 +10,10 @@ Alloc, BalAccountExpectation, BalBalanceChange, + BalCodeChange, + BalNonceChange, + BalStorageChange, + BalStorageSlot, BlockAccessListExpectation, Bytecode, Environment, @@ -458,6 +462,31 @@ def test_selfdestruct_created_in_same_tx_with_revert( # noqa SC200 ], ) ) + else: + account_expectations[ + selfdestruct_with_transfer_contract_address + ] = BalAccountExpectation( + storage_reads=[1], + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + balance_changes=[BalBalanceChange(tx_index=1, post_balance=1)], + code_changes=[ + BalCodeChange( + tx_index=1, + new_code=selfdestruct_with_transfer_contract_code, + ), + ], + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=1), + ], + ), + ], + ) + account_expectations[selfdestruct_recipient_address] = ( + BalAccountExpectation.empty() + ) expected_block_access_list = BlockAccessListExpectation( account_expectations=account_expectations From c45d6b83bdf8a251344b94d8f0d82f2027d0629b Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 30 Nov 2025 15:50:31 +0100 Subject: [PATCH 35/51] test(eip7928): add EXTCODECOPY OOG memory expansion BAL test --- .../test_block_access_lists_opcodes.py | 90 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 91 insertions(+) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index b849137c58..4df25a0869 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -611,6 +611,96 @@ def test_bal_extcodecopy_and_oog( ) +@pytest.mark.parametrize( + "memory_offset,copy_size,gas_shortfall", + [ + pytest.param(0x10000, 32, "large", id="large_offset"), + pytest.param(256, 32, "boundary", id="boundary"), + ], +) +def test_bal_extcodecopy_oog_at_memory_expansion( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + memory_offset: int, + copy_size: int, + gas_shortfall: str, +) -> None: + """ + Test EXTCODECOPY OOG at memory expansion - target should NOT appear in BAL. + + Gas for all components (cold access + copy + memory expansion) must be + checked BEFORE recording account access. + """ + alice = pre.fund_eoa() + gas_costs = fork.gas_costs() + + target_contract = pre.deploy_contract(code=Bytecode(Op.STOP)) + + # Build EXTCODECOPY contract with appropriate PUSH sizes + if memory_offset <= 0xFF: + dest_push = Op.PUSH1(memory_offset) + elif memory_offset <= 0xFFFF: + dest_push = Op.PUSH2(memory_offset) + else: + dest_push = Op.PUSH3(memory_offset) + + extcodecopy_contract_code = Bytecode( + Op.PUSH1(copy_size) + + Op.PUSH1(0) + + dest_push + + Op.PUSH20(target_contract) + + Op.EXTCODECOPY + + Op.STOP + ) + + extcodecopy_contract = pre.deploy_contract(code=extcodecopy_contract_code) + + intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() + intrinsic_gas_cost = intrinsic_gas_calculator() + + push_cost = gas_costs.G_VERY_LOW * 4 + cold_access_cost = gas_costs.G_COLD_ACCOUNT_ACCESS + copy_cost = gas_costs.G_COPY * ((copy_size + 31) // 32) + + if gas_shortfall == "large": + # Provide gas for push + cold access + copy, but NOT memory expansion + execution_cost = push_cost + cold_access_cost + copy_cost + tx_gas_limit = intrinsic_gas_cost + execution_cost + else: + # Calculate memory cost and provide exactly 1 less than needed + words = (memory_offset + copy_size + 31) // 32 + memory_cost = (words * gas_costs.G_MEMORY) + (words * words // 512) + execution_cost = push_cost + cold_access_cost + copy_cost + memory_cost + tx_gas_limit = intrinsic_gas_cost + execution_cost - 1 + + tx = Transaction( + sender=alice, + to=extcodecopy_contract, + gas_limit=tx_gas_limit, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + extcodecopy_contract: BalAccountExpectation.empty(), + target_contract: None, + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + extcodecopy_contract: Account(), + target_contract: Account(), + }, + ) + + def test_bal_storage_write_read_same_frame( pre: Alloc, blockchain_test: BlockchainTestFiller, diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index fd843a4123..9613a8f2c7 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -94,3 +94,4 @@ | `test_call_to_pre_authorized_oog` | Ensure BAL handles OOG during EIP-7702 delegation access (pre-Amsterdam test with BAL) | Call to delegated account that OOGs before accessing delegation contract | BAL **MUST** include auth_signer (code read for delegation check) but **MUST NOT** include delegation contract (OOG before access) | ✅ Completed | | `test_selfdestruct_created_in_same_tx_with_revert` | Ensure BAL tracks selfdestruct with revert correctly (pre-Amsterdam test with BAL) | Contract created and selfdestructed in same tx with nested revert | BAL **MUST** track storage reads and balance changes for selfdestruct even with reverts | ✅ Completed | | `test_value_transfer_gas_calculation` | Ensure BAL correctly tracks OOG scenarios for CALL/CALLCODE/DELEGATECALL/STATICCALL (pre-Amsterdam test with BAL) | Nested calls with precise gas limits to test OOG behavior. For CALL with OOG: target account read for `is_account_alive` check. For CALLCODE/DELEGATECALL/STATICCALL with OOG: target account **NOT** read (OOG before state access) | For CALL: target in BAL even with OOG. For CALLCODE/DELEGATECALL/STATICCALL: target **NOT** in BAL when OOG (state access deferred until after gas check) | ✅ Completed | +| `test_bal_extcodecopy_oog_at_memory_expansion` | Ensure BAL excludes target when EXTCODECOPY OOGs at memory expansion | Parameterized: (1) large_offset: 64KB memory offset, gas covers cold access + copy but NOT memory expansion. (2) boundary: 256 byte offset, gas is exactly 1 less than needed. | BAL **MUST NOT** include target contract. Gas must be checked for ALL components (cold access + copy + memory expansion) BEFORE recording account access. | ✅ Completed | From 1364138bb19b8519f19f7e7b4e67a5cc25a6554d Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 1 Dec 2025 09:58:44 -0700 Subject: [PATCH 36/51] refactor(test-tests): parametrize existing test oog case instead --- .../test_block_access_lists_opcodes.py | 151 +++++++----------- .../test_cases.md | 3 +- 2 files changed, 57 insertions(+), 97 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 4df25a0869..99ad5dcddb 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -529,105 +529,42 @@ def test_bal_delegatecall_and_oog( @pytest.mark.parametrize( - "fails_at_extcodecopy", - [True, False], - ids=["oog_at_extcodecopy", "successful_extcodecopy"], -) -def test_bal_extcodecopy_and_oog( - pre: Alloc, - blockchain_test: BlockchainTestFiller, - fork: Fork, - fails_at_extcodecopy: bool, -) -> None: - """ - Ensure BAL handles EXTCODECOPY and OOG during EXTCODECOPY appropriately. - """ - alice = pre.fund_eoa() - gas_costs = fork.gas_costs() - - # Create target contract with some code - target_contract = pre.deploy_contract( - code=Bytecode(Op.PUSH1(0x42) + Op.STOP) - ) - - # Create contract that attempts to copy code from target - extcodecopy_contract_code = Bytecode( - Op.PUSH1(0) # size - copy 0 bytes to minimize memory expansion cost - + Op.PUSH1(0) # codeOffset - + Op.PUSH1(0) # destOffset - + Op.PUSH20(target_contract) # address - + Op.EXTCODECOPY # Copy code (cold access + base cost) - + Op.STOP - ) - - extcodecopy_contract = pre.deploy_contract(code=extcodecopy_contract_code) - - intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() - intrinsic_gas_cost = intrinsic_gas_calculator() - - # Costs: - # - 4 PUSH operations = G_VERY_LOW * 4 - # - EXTCODECOPY cold = G_COLD_ACCOUNT_ACCESS + (G_COPY * words) - # where words = ceil32(size) // 32 = ceil32(0) // 32 = 0 - push_cost = gas_costs.G_VERY_LOW * 4 - extcodecopy_cold_cost = ( - gas_costs.G_COLD_ACCOUNT_ACCESS - ) # + (G_COPY * 0) = 0 - tx_gas_limit = intrinsic_gas_cost + push_cost + extcodecopy_cold_cost - - if fails_at_extcodecopy: - # subtract 1 gas to ensure OOG at EXTCODECOPY - tx_gas_limit -= 1 - - tx = Transaction( - sender=alice, - to=extcodecopy_contract, - gas_limit=tx_gas_limit, - ) - - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - extcodecopy_contract: BalAccountExpectation.empty(), - # Target should only appear if EXTCODECOPY succeeded - **( - {target_contract: None} - if fails_at_extcodecopy - else {target_contract: BalAccountExpectation.empty()} - ), - } - ), - ) - - blockchain_test( - pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - extcodecopy_contract: Account(), - target_contract: Account(), - }, - ) - - -@pytest.mark.parametrize( - "memory_offset,copy_size,gas_shortfall", + "oog_scenario,memory_offset,copy_size", [ - pytest.param(0x10000, 32, "large", id="large_offset"), - pytest.param(256, 32, "boundary", id="boundary"), + pytest.param("success", 0, 0, id="successful_extcodecopy"), + pytest.param("oog_at_cold_access", 0, 0, id="oog_at_cold_access"), + pytest.param( + "oog_at_memory_large_offset", + 0x10000, + 32, + id="oog_at_memory_large_offset", + ), + pytest.param( + "oog_at_memory_boundary", + 256, + 32, + id="oog_at_memory_boundary", + ), ], ) -def test_bal_extcodecopy_oog_at_memory_expansion( +def test_bal_extcodecopy_and_oog( pre: Alloc, blockchain_test: BlockchainTestFiller, fork: Fork, + oog_scenario: str, memory_offset: int, copy_size: int, - gas_shortfall: str, ) -> None: """ - Test EXTCODECOPY OOG at memory expansion - target should NOT appear in BAL. + Ensure BAL handles EXTCODECOPY and OOG during EXTCODECOPY appropriately. + + Tests various OOG scenarios: + - success: EXTCODECOPY completes, target appears in BAL + - oog_at_cold_access: OOG before cold access, target NOT in BAL + - oog_at_memory_large_offset: OOG at memory expansion (large offset), + target NOT in BAL + - oog_at_memory_boundary: OOG at memory expansion (boundary case), + target NOT in BAL Gas for all components (cold access + copy + memory expansion) must be checked BEFORE recording account access. @@ -635,7 +572,10 @@ def test_bal_extcodecopy_oog_at_memory_expansion( alice = pre.fund_eoa() gas_costs = fork.gas_costs() - target_contract = pre.deploy_contract(code=Bytecode(Op.STOP)) + # Create target contract with some code + target_contract = pre.deploy_contract( + code=Bytecode(Op.PUSH1(0x42) + Op.STOP) + ) # Build EXTCODECOPY contract with appropriate PUSH sizes if memory_offset <= 0xFF: @@ -647,8 +587,8 @@ def test_bal_extcodecopy_oog_at_memory_expansion( extcodecopy_contract_code = Bytecode( Op.PUSH1(copy_size) - + Op.PUSH1(0) - + dest_push + + Op.PUSH1(0) # codeOffset + + dest_push # destOffset + Op.PUSH20(target_contract) + Op.EXTCODECOPY + Op.STOP @@ -659,20 +599,37 @@ def test_bal_extcodecopy_oog_at_memory_expansion( intrinsic_gas_calculator = fork.transaction_intrinsic_cost_calculator() intrinsic_gas_cost = intrinsic_gas_calculator() + # Calculate costs push_cost = gas_costs.G_VERY_LOW * 4 cold_access_cost = gas_costs.G_COLD_ACCOUNT_ACCESS copy_cost = gas_costs.G_COPY * ((copy_size + 31) // 32) - if gas_shortfall == "large": + if oog_scenario == "success": + # Provide enough gas for everything including memory expansion + words = (memory_offset + copy_size + 31) // 32 + memory_cost = (words * gas_costs.G_MEMORY) + (words * words // 512) + execution_cost = push_cost + cold_access_cost + copy_cost + memory_cost + tx_gas_limit = intrinsic_gas_cost + execution_cost + target_in_bal = True + elif oog_scenario == "oog_at_cold_access": + # Provide gas for pushes but 1 less than cold access cost + execution_cost = push_cost + cold_access_cost + tx_gas_limit = intrinsic_gas_cost + execution_cost - 1 + target_in_bal = False + elif oog_scenario == "oog_at_memory_large_offset": # Provide gas for push + cold access + copy, but NOT memory expansion execution_cost = push_cost + cold_access_cost + copy_cost tx_gas_limit = intrinsic_gas_cost + execution_cost - else: + target_in_bal = False + elif oog_scenario == "oog_at_memory_boundary": # Calculate memory cost and provide exactly 1 less than needed words = (memory_offset + copy_size + 31) // 32 memory_cost = (words * gas_costs.G_MEMORY) + (words * words // 512) execution_cost = push_cost + cold_access_cost + copy_cost + memory_cost tx_gas_limit = intrinsic_gas_cost + execution_cost - 1 + target_in_bal = False + else: + raise ValueError(f"Invariant: unknown oog_scenario {oog_scenario}") tx = Transaction( sender=alice, @@ -685,7 +642,11 @@ def test_bal_extcodecopy_oog_at_memory_expansion( expected_block_access_list=BlockAccessListExpectation( account_expectations={ extcodecopy_contract: BalAccountExpectation.empty(), - target_contract: None, + **( + {target_contract: BalAccountExpectation.empty()} + if target_in_bal + else {target_contract: None} + ), } ), ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 9613a8f2c7..5c7bcb42b4 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -49,7 +49,7 @@ | `test_bal_extcodesize_and_oog` | Ensure BAL handles OOG during EXTCODESIZE opcode execution correctly | Alice calls contract that attempts `EXTCODESIZE` opcode on cold target contract. Parameterized: (1) OOG at EXTCODESIZE opcode (insufficient gas), (2) Successful EXTCODESIZE execution. | For OOG case: BAL **MUST NOT** include target contract (wasn't accessed). For success case: BAL **MUST** include target contract in `account_changes`. | ✅ Completed | | `test_bal_call_and_oog` | Ensure BAL handles OOG during CALL opcode execution correctly | Alice calls contract that attempts `CALL` to cold target contract. Parameterized: (1) OOG at CALL opcode (insufficient gas), (2) Successful CALL execution. | For OOG case: BAL **MUST NOT** include target contract (wasn't accessed). For success case: BAL **MUST** include target contract in `account_changes`. | ✅ Completed | | `test_bal_delegatecall_and_oog` | Ensure BAL handles OOG during DELEGATECALL opcode execution correctly | Alice calls contract that attempts `DELEGATECALL` to cold target contract. Parameterized: (1) OOG at DELEGATECALL opcode (insufficient gas), (2) Successful DELEGATECALL execution. | For OOG case: BAL **MUST NOT** include target contract (wasn't accessed). For success case: BAL **MUST** include target contract in `account_changes`. | ✅ Completed | -| `test_bal_extcodecopy_and_oog` | Ensure BAL handles OOG during EXTCODECOPY opcode execution correctly | Alice calls contract that attempts `EXTCODECOPY` from cold target contract. Parameterized: (1) OOG at EXTCODECOPY opcode (insufficient gas), (2) Successful EXTCODECOPY execution. | For OOG case: BAL **MUST NOT** include target contract (wasn't accessed). For success case: BAL **MUST** include target contract in `account_changes`. | ✅ Completed | +| `test_bal_extcodecopy_and_oog` | Ensure BAL handles OOG during EXTCODECOPY at various failure points | Alice calls contract that attempts `EXTCODECOPY` from cold target contract. Parameterized: (1) Successful EXTCODECOPY, (2) OOG at cold access (insufficient gas for account access), (3) OOG at memory expansion with large offset (64KB offset, gas covers cold access + copy but NOT memory expansion), (4) OOG at memory expansion boundary (256 byte offset, gas is exactly 1 less than needed). | For success case: BAL **MUST** include target contract. For all OOG cases: BAL **MUST NOT** include target contract. Gas for ALL components (cold access + copy + memory expansion) must be checked BEFORE recording account access. | ✅ Completed | | `test_bal_oog_7702_delegated_cold_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when both accounts are cold | Alice calls cold delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (first cold load succeeds) but **MUST NOT** include `TargetContract` (second cold load fails due to OOG) | 🟡 Planned | | `test_bal_oog_7702_delegated_warm_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when first account is warm, second is cold | Alice calls warm delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (warm load succeeds) but **MUST NOT** include `TargetContract` (cold load fails due to OOG) | 🟡 Planned | | `test_bal_multiple_balance_changes_same_account` | Ensure BAL tracks multiple balance changes to same account across transactions | Alice funds Bob (starts at 0) in tx0 with exact amount needed. Bob spends everything in tx1 to Charlie. Bob's balance: 0 → funding_amount → 0 | BAL **MUST** include Bob with two `balance_changes`: one at txIndex=1 (receives funds) and one at txIndex=2 (balance returns to 0). This tests balance tracking across two transactions. | ✅ Completed | @@ -94,4 +94,3 @@ | `test_call_to_pre_authorized_oog` | Ensure BAL handles OOG during EIP-7702 delegation access (pre-Amsterdam test with BAL) | Call to delegated account that OOGs before accessing delegation contract | BAL **MUST** include auth_signer (code read for delegation check) but **MUST NOT** include delegation contract (OOG before access) | ✅ Completed | | `test_selfdestruct_created_in_same_tx_with_revert` | Ensure BAL tracks selfdestruct with revert correctly (pre-Amsterdam test with BAL) | Contract created and selfdestructed in same tx with nested revert | BAL **MUST** track storage reads and balance changes for selfdestruct even with reverts | ✅ Completed | | `test_value_transfer_gas_calculation` | Ensure BAL correctly tracks OOG scenarios for CALL/CALLCODE/DELEGATECALL/STATICCALL (pre-Amsterdam test with BAL) | Nested calls with precise gas limits to test OOG behavior. For CALL with OOG: target account read for `is_account_alive` check. For CALLCODE/DELEGATECALL/STATICCALL with OOG: target account **NOT** read (OOG before state access) | For CALL: target in BAL even with OOG. For CALLCODE/DELEGATECALL/STATICCALL: target **NOT** in BAL when OOG (state access deferred until after gas check) | ✅ Completed | -| `test_bal_extcodecopy_oog_at_memory_expansion` | Ensure BAL excludes target when EXTCODECOPY OOGs at memory expansion | Parameterized: (1) large_offset: 64KB memory offset, gas covers cold access + copy but NOT memory expansion. (2) boundary: 256 byte offset, gas is exactly 1 less than needed. | BAL **MUST NOT** include target contract. Gas must be checked for ALL components (cold access + copy + memory expansion) BEFORE recording account access. | ✅ Completed | From 0a60532dedc4a8384b40b0db051de44a2f101444 Mon Sep 17 00:00:00 2001 From: Stefan Date: Mon, 1 Dec 2025 17:09:31 +0100 Subject: [PATCH 37/51] test(eip7928): add cross-block precompile state leak test --- .../test_block_access_lists.py | 77 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 78 insertions(+) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 14a9ab4bdf..f062d7eadf 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -2206,3 +2206,80 @@ def test_bal_cross_tx_storage_revert_to_zero( contract: Account(storage={0: 0x0}), }, ) + + +# RIPEMD-160 precompile address (used in Parity Touch Bug test) +RIPEMD_160 = Address(0x03) + + +def test_bal_cross_block_precompile_state_leak( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Ensure internal EVM state for precompile handling does not leak between blocks. + + The EVM may track internal state related to the Parity Touch Bug (EIP-161) + when calling RIPEMD-160 (0x03) with zero value. If this state is not properly + reset between blocks, it can cause incorrect BAL entries in subsequent blocks. + + Prerequisites for triggering the bug: + 1. RIPEMD-160 (0x03) must already exist in state before the call. + 2. Block 1 must call RIPEMD-160 with zero value and complete successfully. + 3. Block 2 must have a TX that triggers an exception (not REVERT). + + Expected behavior: + - Block 1: RIPEMD-160 in BAL (legitimate access) + - Block 2: RIPEMD-160 NOT in BAL (never touched in this block) + + Bug behavior: + - Block 2 incorrectly has RIPEMD-160 in its BAL due to leaked internal state. + """ + alice = pre.fund_eoa() + bob = pre.fund_eoa() + + # Pre-fund RIPEMD-160 so it exists before the call. + # This is required to trigger the internal state tracking. + pre[RIPEMD_160] = Account(balance=1) + + # Contract that calls RIPEMD-160 with zero value + ripemd_caller = pre.deploy_contract( + code=Op.CALL(50_000, RIPEMD_160, 0, 0, 0, 0, 0) + Op.STOP + ) + + # Contract that triggers an exception (stack underflow from ADD on empty stack) + exception_contract = pre.deploy_contract(code=Op.ADD) + + # Block 1: Call RIPEMD-160 successfully + block1 = Block( + txs=[ + Transaction( + sender=alice, + to=ripemd_caller, + gas_limit=100_000, + ) + ], + ) + + # Block 2: Exception triggers internal exception handling. + # If internal state leaked from Block 1, RIPEMD-160 would incorrectly + # appear in Block 2's BAL. + block2 = Block( + txs=[ + Transaction( + sender=bob, + to=exception_contract, + gas_limit=100_000, + ) + ], + ) + + blockchain_test( + pre=pre, + blocks=[block1, block2], + post={ + alice: Account(nonce=1), + bob: Account(nonce=1), + RIPEMD_160: Account(balance=1), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 5c7bcb42b4..dbcf8ce31a 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -94,3 +94,4 @@ | `test_call_to_pre_authorized_oog` | Ensure BAL handles OOG during EIP-7702 delegation access (pre-Amsterdam test with BAL) | Call to delegated account that OOGs before accessing delegation contract | BAL **MUST** include auth_signer (code read for delegation check) but **MUST NOT** include delegation contract (OOG before access) | ✅ Completed | | `test_selfdestruct_created_in_same_tx_with_revert` | Ensure BAL tracks selfdestruct with revert correctly (pre-Amsterdam test with BAL) | Contract created and selfdestructed in same tx with nested revert | BAL **MUST** track storage reads and balance changes for selfdestruct even with reverts | ✅ Completed | | `test_value_transfer_gas_calculation` | Ensure BAL correctly tracks OOG scenarios for CALL/CALLCODE/DELEGATECALL/STATICCALL (pre-Amsterdam test with BAL) | Nested calls with precise gas limits to test OOG behavior. For CALL with OOG: target account read for `is_account_alive` check. For CALLCODE/DELEGATECALL/STATICCALL with OOG: target account **NOT** read (OOG before state access) | For CALL: target in BAL even with OOG. For CALLCODE/DELEGATECALL/STATICCALL: target **NOT** in BAL when OOG (state access deferred until after gas check) | ✅ Completed | +| `test_bal_cross_block_precompile_state_leak` | Ensure internal EVM state for precompile handling does not leak between blocks | Block 1: Alice calls RIPEMD-160 (0x03) with zero value (RIPEMD-160 must be pre-funded). Block 2: Bob's transaction triggers an exception (stack underflow). | BAL for Block 1 **MUST** include RIPEMD-160. BAL for Block 2 **MUST NOT** include RIPEMD-160 (never accessed in Block 2). Internal state from Parity Touch Bug (EIP-161) handling must be reset between blocks. | ✅ Completed | From 7f73c1a2f989615b9088615cb05d1e67b24d1a62 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 1 Dec 2025 10:49:02 -0700 Subject: [PATCH 38/51] refactor(test-tests): Add BAL expectation to state leak test; fix lint --- .../test_block_access_lists.py | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index f062d7eadf..09f578d286 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -2208,20 +2208,18 @@ def test_bal_cross_tx_storage_revert_to_zero( ) -# RIPEMD-160 precompile address (used in Parity Touch Bug test) -RIPEMD_160 = Address(0x03) - - -def test_bal_cross_block_precompile_state_leak( +def test_bal_cross_block_ripemd160_state_leak( pre: Alloc, blockchain_test: BlockchainTestFiller, ) -> None: """ - Ensure internal EVM state for precompile handling does not leak between blocks. + Ensure internal EVM state for RIMPEMD-160 precompile handling does not + leak between blocks. The EVM may track internal state related to the Parity Touch Bug (EIP-161) - when calling RIPEMD-160 (0x03) with zero value. If this state is not properly - reset between blocks, it can cause incorrect BAL entries in subsequent blocks. + when calling RIPEMD-160 (0x03) with zero value. If this state is not + properly reset between blocks, it can cause incorrect BAL entries in + subsequent blocks. Prerequisites for triggering the bug: 1. RIPEMD-160 (0x03) must already exist in state before the call. @@ -2233,21 +2231,22 @@ def test_bal_cross_block_precompile_state_leak( - Block 2: RIPEMD-160 NOT in BAL (never touched in this block) Bug behavior: - - Block 2 incorrectly has RIPEMD-160 in its BAL due to leaked internal state. + - Block 2 incorrectly has RIPEMD-160 in its BAL due to leaked + internal state. """ alice = pre.fund_eoa() bob = pre.fund_eoa() - # Pre-fund RIPEMD-160 so it exists before the call. # This is required to trigger the internal state tracking. - pre[RIPEMD_160] = Account(balance=1) + ripemd160_addr = Address(0x03) + pre.fund_address(ripemd160_addr, amount=1) # Contract that calls RIPEMD-160 with zero value ripemd_caller = pre.deploy_contract( - code=Op.CALL(50_000, RIPEMD_160, 0, 0, 0, 0, 0) + Op.STOP + code=Op.CALL(50_000, ripemd160_addr, 0, 0, 0, 0, 0) + Op.STOP ) - - # Contract that triggers an exception (stack underflow from ADD on empty stack) + # Contract that triggers an exception + # (stack underflow from ADD on empty stack) exception_contract = pre.deploy_contract(code=Op.ADD) # Block 1: Call RIPEMD-160 successfully @@ -2259,6 +2258,16 @@ def test_bal_cross_block_precompile_state_leak( gas_limit=100_000, ) ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + ), + bob: None, + ripemd_caller: BalAccountExpectation.empty(), + ripemd160_addr: BalAccountExpectation.empty(), + } + ), ) # Block 2: Exception triggers internal exception handling. @@ -2272,6 +2281,16 @@ def test_bal_cross_block_precompile_state_leak( gas_limit=100_000, ) ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: None, + bob: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)] + ), + # this is the important check + ripemd160_addr: None, + } + ), ) blockchain_test( @@ -2280,6 +2299,6 @@ def test_bal_cross_block_precompile_state_leak( post={ alice: Account(nonce=1), bob: Account(nonce=1), - RIPEMD_160: Account(balance=1), + ripemd160_addr: Account(balance=1), }, ) From 796c50559ed29f3e555019b19c9a88b4ea67cdad Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 4 Dec 2025 18:23:25 +0100 Subject: [PATCH 39/51] eip7928: add SELFDESTRUCT OOG BAL test --- .../test_block_access_lists.py | 70 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 71 insertions(+) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 09f578d286..beee7982aa 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -373,6 +373,76 @@ def test_bal_self_destruct( ) +def test_bal_self_destruct_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test that SELFDESTRUCT beneficiary is NOT included in BAL when OOG. + + When SELFDESTRUCT runs out of gas, the operation fails and the beneficiary + address should NOT be added to the Block Access List. + + This test: + 1. Deploys a contract with SELFDESTRUCT bytecode + 2. Calls the contract with limited gas so SELFDESTRUCT fails OOG + 3. Verifies beneficiary is NOT in BAL (the CALL reverts, undoing BAL changes) + + SELFDESTRUCT gas cost to cold new account: 5000 + 2600 + 25000 = 32600 gas + """ + alice = pre.fund_eoa() + + # Beneficiary address for SELFDESTRUCT + beneficiary = Address(0xBEEF) + + # Contract: PUSH20 SELFDESTRUCT + selfdestruct_code = Op.SELFDESTRUCT(beneficiary) + selfdestruct_contract = pre.deploy_contract(code=selfdestruct_code, balance=1000) + + # Caller contract: CALL with limited gas to cause OOG on SELFDESTRUCT + # SELFDESTRUCT needs 32600 gas, we give it only 100 + caller_code = ( + Op.CALL(gas=100, address=selfdestruct_contract, value=0, + args_offset=0, args_size=0, ret_offset=0, ret_size=0) + + Op.STOP + ) + caller_contract = pre.deploy_contract(code=caller_code) + + tx = Transaction( + sender=alice, + to=caller_contract, + gas_limit=100_000, + gas_price=0xA, + ) + + # The inner CALL fails OOG, so SELFDESTRUCT doesn't complete. + # Beneficiary should NOT be in BAL. + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + caller_contract: BalAccountExpectation.empty(), + selfdestruct_contract: BalAccountExpectation.empty(), + # beneficiary should NOT appear - SELFDESTRUCT failed OOG + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + caller_contract: Account(code=caller_code), + # Contract still exists - SELFDESTRUCT failed + selfdestruct_contract: Account(balance=1000, code=selfdestruct_code), + }, + ) + + @pytest.mark.parametrize( "account_access_opcode", [ diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index dbcf8ce31a..06287528ef 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -95,3 +95,4 @@ | `test_selfdestruct_created_in_same_tx_with_revert` | Ensure BAL tracks selfdestruct with revert correctly (pre-Amsterdam test with BAL) | Contract created and selfdestructed in same tx with nested revert | BAL **MUST** track storage reads and balance changes for selfdestruct even with reverts | ✅ Completed | | `test_value_transfer_gas_calculation` | Ensure BAL correctly tracks OOG scenarios for CALL/CALLCODE/DELEGATECALL/STATICCALL (pre-Amsterdam test with BAL) | Nested calls with precise gas limits to test OOG behavior. For CALL with OOG: target account read for `is_account_alive` check. For CALLCODE/DELEGATECALL/STATICCALL with OOG: target account **NOT** read (OOG before state access) | For CALL: target in BAL even with OOG. For CALLCODE/DELEGATECALL/STATICCALL: target **NOT** in BAL when OOG (state access deferred until after gas check) | ✅ Completed | | `test_bal_cross_block_precompile_state_leak` | Ensure internal EVM state for precompile handling does not leak between blocks | Block 1: Alice calls RIPEMD-160 (0x03) with zero value (RIPEMD-160 must be pre-funded). Block 2: Bob's transaction triggers an exception (stack underflow). | BAL for Block 1 **MUST** include RIPEMD-160. BAL for Block 2 **MUST NOT** include RIPEMD-160 (never accessed in Block 2). Internal state from Parity Touch Bug (EIP-161) handling must be reset between blocks. | ✅ Completed | +| `test_bal_self_destruct_oog` | Ensure BAL does not include SELFDESTRUCT beneficiary when operation fails due to OOG | Alice calls `Caller` contract which CALLs `SelfDestructContract` with limited gas (100). `SelfDestructContract` attempts SELFDESTRUCT to `Beneficiary`. SELFDESTRUCT requires 32600 gas (5000 base + 2600 cold + 25000 new account). | BAL **MUST** include Alice with `nonce_changes`, `Caller` with empty changes, `SelfDestructContract` with empty changes. BAL **MUST NOT** include `Beneficiary` (SELFDESTRUCT failed OOG, CALL reverted, BAL changes rolled back). Contract balance unchanged. | ✅ Completed | From 70d6f71ad0ef527646161cefa5642fd0bf00feb1 Mon Sep 17 00:00:00 2001 From: Felipe Selmo Date: Thu, 4 Dec 2025 23:48:41 +0000 Subject: [PATCH 40/51] refactor(tests): move selfdestruct bal tests to oog file; add gas boundaries --- .../forks/amsterdam/vm/instructions/system.py | 6 +- .../test_block_access_lists.py | 229 +--------------- .../test_block_access_lists_opcodes.py | 248 +++++++++++++++++- 3 files changed, 251 insertions(+), 232 deletions(-) diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 1fca8b1459..288594bfe7 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -625,6 +625,9 @@ def selfdestruct(evm: Evm) -> None: if is_cold_access: gas_cost += GAS_COLD_ACCOUNT_ACCESS + check_gas(evm, gas_cost) + + # is_account_alive requires account to be accessed, check gas before if ( not is_account_alive(evm.message.block_env.state, beneficiary) and get_account( @@ -634,11 +637,8 @@ def selfdestruct(evm: Evm) -> None: ): gas_cost += GAS_SELF_DESTRUCT_NEW_ACCOUNT - check_gas(evm, gas_cost) - if is_cold_access: evm.accessed_addresses.add(beneficiary) - track_address(evm.state_changes, beneficiary) charge_gas(evm, gas_cost) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index beee7982aa..50fb9e0ad2 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -1,6 +1,6 @@ """Tests for EIP-7928 using the consistent data class pattern.""" -from typing import Callable, Dict +from typing import Callable import pytest from execution_testing import ( @@ -21,7 +21,6 @@ Fork, Hash, Header, - Initcode, Op, Transaction, compute_create_address, @@ -217,232 +216,6 @@ def test_bal_code_changes( ) -@pytest.mark.parametrize( - "self_destruct_in_same_tx", [True, False], ids=["same_tx", "new_tx"] -) -@pytest.mark.parametrize( - "pre_funded", [True, False], ids=["pre_funded", "not_pre_funded"] -) -def test_bal_self_destruct( - pre: Alloc, - blockchain_test: BlockchainTestFiller, - self_destruct_in_same_tx: bool, - pre_funded: bool, -) -> None: - """Ensure BAL captures balance changes caused by `SELFDESTRUCT`.""" - alice = pre.fund_eoa() - bob = pre.fund_eoa(amount=0) - - selfdestruct_code = ( - Op.SLOAD(0x01) # Read from storage slot 0x01 - + Op.SSTORE(0x02, 0x42) # Write to storage slot 0x02 - + Op.SELFDESTRUCT(bob) - ) - # A pre existing self-destruct contract with initial storage - kaboom = pre.deploy_contract(code=selfdestruct_code, storage={0x01: 0x123}) - - # A template for self-destruct contract - self_destruct_init_code = Initcode(deploy_code=selfdestruct_code) - template = pre.deploy_contract(code=self_destruct_init_code) - - transfer_amount = expected_recipient_balance = 100 - pre_fund_amount = 10 - - if self_destruct_in_same_tx: - # The goal is to create a self-destructing contract in the same - # transaction to trigger deletion of code as per EIP-6780. - # The factory contract below creates a new self-destructing - # contract and calls it in this transaction. - - bytecode_size = len(self_destruct_init_code) - factory_bytecode = ( - # Clone template memory - Op.EXTCODECOPY(template, 0, 0, bytecode_size) - # Fund 100 wei and deploy the clone - + Op.CREATE(transfer_amount, 0, bytecode_size) - # Call the clone, which self-destructs - + Op.CALL(100_000, Op.DUP6, 0, 0, 0, 0, 0) - + Op.STOP - ) - - factory = pre.deploy_contract(code=factory_bytecode) - kaboom_same_tx = compute_create_address(address=factory, nonce=1) - - # Determine which account will be self-destructed - self_destructed_account = ( - kaboom_same_tx if self_destruct_in_same_tx else kaboom - ) - - if pre_funded: - expected_recipient_balance += pre_fund_amount - pre.fund_address( - address=self_destructed_account, amount=pre_fund_amount - ) - - tx = Transaction( - sender=alice, - to=factory if self_destruct_in_same_tx else kaboom, - value=transfer_amount, - gas_limit=1_000_000, - gas_price=0xA, - ) - - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], - ), - bob: BalAccountExpectation( - balance_changes=[ - BalBalanceChange( - tx_index=1, post_balance=expected_recipient_balance - ) - ] - ), - self_destructed_account: BalAccountExpectation( - balance_changes=[ - BalBalanceChange(tx_index=1, post_balance=0) - ] - if pre_funded - else [], - # Accessed slots for same-tx are recorded as reads (0x02) - storage_reads=[0x01, 0x02] - if self_destruct_in_same_tx - else [0x01], - # Storage changes are recorded for non-same-tx - # self-destructs - storage_changes=[ - BalStorageSlot( - slot=0x02, - slot_changes=[ - BalStorageChange(tx_index=1, post_value=0x42) - ], - ) - ] - if not self_destruct_in_same_tx - else [], - code_changes=[], # should not be present - nonce_changes=[], # should not be present - ), - } - ), - ) - - post: Dict[Address, Account] = { - alice: Account(nonce=1), - bob: Account(balance=expected_recipient_balance), - } - - # If the account was self-destructed in the same transaction, - # we expect the account to non-existent and its balance to be 0. - if self_destruct_in_same_tx: - post.update( - { - factory: Account( - nonce=2, # incremented after CREATE - balance=0, # spent on CREATE - code=factory_bytecode, - ), - kaboom_same_tx: Account.NONEXISTENT, # type: ignore - # The pre-existing contract remains unaffected - kaboom: Account( - balance=0, code=selfdestruct_code, storage={0x01: 0x123} - ), - } - ) - else: - post.update( - { - # This contract was self-destructed in a separate tx. - # From EIP 6780: `SELFDESTRUCT` does not delete any data - # (including storage keys, code, or the account itself). - kaboom: Account( - balance=0, - code=selfdestruct_code, - storage={0x01: 0x123, 0x2: 0x42}, - ), - } - ) - - blockchain_test( - pre=pre, - blocks=[block], - post=post, - ) - - -def test_bal_self_destruct_oog( - pre: Alloc, - blockchain_test: BlockchainTestFiller, -) -> None: - """ - Test that SELFDESTRUCT beneficiary is NOT included in BAL when OOG. - - When SELFDESTRUCT runs out of gas, the operation fails and the beneficiary - address should NOT be added to the Block Access List. - - This test: - 1. Deploys a contract with SELFDESTRUCT bytecode - 2. Calls the contract with limited gas so SELFDESTRUCT fails OOG - 3. Verifies beneficiary is NOT in BAL (the CALL reverts, undoing BAL changes) - - SELFDESTRUCT gas cost to cold new account: 5000 + 2600 + 25000 = 32600 gas - """ - alice = pre.fund_eoa() - - # Beneficiary address for SELFDESTRUCT - beneficiary = Address(0xBEEF) - - # Contract: PUSH20 SELFDESTRUCT - selfdestruct_code = Op.SELFDESTRUCT(beneficiary) - selfdestruct_contract = pre.deploy_contract(code=selfdestruct_code, balance=1000) - - # Caller contract: CALL with limited gas to cause OOG on SELFDESTRUCT - # SELFDESTRUCT needs 32600 gas, we give it only 100 - caller_code = ( - Op.CALL(gas=100, address=selfdestruct_contract, value=0, - args_offset=0, args_size=0, ret_offset=0, ret_size=0) - + Op.STOP - ) - caller_contract = pre.deploy_contract(code=caller_code) - - tx = Transaction( - sender=alice, - to=caller_contract, - gas_limit=100_000, - gas_price=0xA, - ) - - # The inner CALL fails OOG, so SELFDESTRUCT doesn't complete. - # Beneficiary should NOT be in BAL. - block = Block( - txs=[tx], - expected_block_access_list=BlockAccessListExpectation( - account_expectations={ - alice: BalAccountExpectation( - nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], - ), - caller_contract: BalAccountExpectation.empty(), - selfdestruct_contract: BalAccountExpectation.empty(), - # beneficiary should NOT appear - SELFDESTRUCT failed OOG - } - ), - ) - - blockchain_test( - pre=pre, - blocks=[block], - post={ - alice: Account(nonce=1), - caller_contract: Account(code=caller_code), - # Contract still exists - SELFDESTRUCT failed - selfdestruct_contract: Account(balance=1000, code=selfdestruct_code), - }, - ) - - @pytest.mark.parametrize( "account_access_opcode", [ diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index 99ad5dcddb..bae1544eee 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -15,13 +15,15 @@ """ from enum import Enum -from typing import Callable +from typing import Callable, Dict import pytest from execution_testing import ( Account, + Address, Alloc, BalAccountExpectation, + BalBalanceChange, BalNonceChange, BalStorageChange, BalStorageSlot, @@ -30,6 +32,7 @@ BlockchainTestFiller, Bytecode, Fork, + Initcode, Op, Transaction, compute_create_address, @@ -662,6 +665,249 @@ def test_bal_extcodecopy_and_oog( ) +@pytest.mark.parametrize( + "self_destruct_in_same_tx", [True, False], ids=["same_tx", "new_tx"] +) +@pytest.mark.parametrize( + "pre_funded", [True, False], ids=["pre_funded", "not_pre_funded"] +) +def test_bal_self_destruct( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + self_destruct_in_same_tx: bool, + pre_funded: bool, +) -> None: + """Ensure BAL captures balance changes caused by `SELFDESTRUCT`.""" + alice = pre.fund_eoa() + bob = pre.fund_eoa(amount=0) + + selfdestruct_code = ( + Op.SLOAD(0x01) # Read from storage slot 0x01 + + Op.SSTORE(0x02, 0x42) # Write to storage slot 0x02 + + Op.SELFDESTRUCT(bob) + ) + # A pre existing self-destruct contract with initial storage + kaboom = pre.deploy_contract(code=selfdestruct_code, storage={0x01: 0x123}) + + # A template for self-destruct contract + self_destruct_init_code = Initcode(deploy_code=selfdestruct_code) + template = pre.deploy_contract(code=self_destruct_init_code) + + transfer_amount = expected_recipient_balance = 100 + pre_fund_amount = 10 + + if self_destruct_in_same_tx: + # The goal is to create a self-destructing contract in the same + # transaction to trigger deletion of code as per EIP-6780. + # The factory contract below creates a new self-destructing + # contract and calls it in this transaction. + + bytecode_size = len(self_destruct_init_code) + factory_bytecode = ( + # Clone template memory + Op.EXTCODECOPY(template, 0, 0, bytecode_size) + # Fund 100 wei and deploy the clone + + Op.CREATE(transfer_amount, 0, bytecode_size) + # Call the clone, which self-destructs + + Op.CALL(1_000_000, Op.DUP6, 0, 0, 0, 0, 0) + + Op.STOP + ) + + factory = pre.deploy_contract(code=factory_bytecode) + kaboom_same_tx = compute_create_address(address=factory, nonce=1) + + # Determine which account will be self-destructed + self_destructed_account = ( + kaboom_same_tx if self_destruct_in_same_tx else kaboom + ) + + if pre_funded: + expected_recipient_balance += pre_fund_amount + pre.fund_address( + address=self_destructed_account, amount=pre_fund_amount + ) + + tx = Transaction( + sender=alice, + to=factory if self_destruct_in_same_tx else kaboom, + value=transfer_amount, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + bob: BalAccountExpectation( + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=expected_recipient_balance + ) + ] + ), + self_destructed_account: BalAccountExpectation( + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=0) + ] + if pre_funded + else [], + # Accessed slots for same-tx are recorded as reads (0x02) + storage_reads=[0x01, 0x02] + if self_destruct_in_same_tx + else [0x01], + # Storage changes are recorded for non-same-tx + # self-destructs + storage_changes=[ + BalStorageSlot( + slot=0x02, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0x42) + ], + ) + ] + if not self_destruct_in_same_tx + else [], + code_changes=[], # should not be present + nonce_changes=[], # should not be present + ), + } + ), + ) + + post: Dict[Address, Account] = { + alice: Account(nonce=1), + bob: Account(balance=expected_recipient_balance), + } + + # If the account was self-destructed in the same transaction, + # we expect the account to non-existent and its balance to be 0. + if self_destruct_in_same_tx: + post.update( + { + factory: Account( + nonce=2, # incremented after CREATE + balance=0, # spent on CREATE + code=factory_bytecode, + ), + kaboom_same_tx: Account.NONEXISTENT, # type: ignore + # The pre-existing contract remains unaffected + kaboom: Account( + balance=0, code=selfdestruct_code, storage={0x01: 0x123} + ), + } + ) + else: + post.update( + { + # This contract was self-destructed in a separate tx. + # From EIP 6780: `SELFDESTRUCT` does not delete any data + # (including storage keys, code, or the account itself). + kaboom: Account( + balance=0, + code=selfdestruct_code, + storage={0x01: 0x123, 0x2: 0x42}, + ), + } + ) + + blockchain_test( + pre=pre, + blocks=[block], + post=post, + ) + + +@pytest.mark.parametrize("oog_before_state_access", [True, False]) +def test_bal_self_destruct_oog( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + fork: Fork, + oog_before_state_access: bool, +) -> None: + """ + Test SELFDESTRUCT BAL behavior at gas boundaries. + + SELFDESTRUCT has two gas checkpoints: + 1. static checks: G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS + OOG here = no state access, beneficiary NOT in BAL + 2. state access: same as static checks, plus G_NEW_ACCOUNT for new account + OOG here = enough gas to access state but not enough for new account, + beneficiary IS in BAL + """ + alice = pre.fund_eoa() + # always use new account so we incur extra G_NEW_ACCOUNT cost + # there is no other gas boundary to test between cold access + # and new account + beneficiary = pre.empty_account() + + # selfdestruct_contract: PUSH20 SELFDESTRUCT + selfdestruct_code = Op.SELFDESTRUCT(beneficiary) + selfdestruct_contract = pre.deploy_contract( + code=selfdestruct_code, balance=1000 + ) + + # Gas needed inside the CALL for SELFDESTRUCT: + # - PUSH20: G_VERY_LOW = 3 + # - SELFDESTRUCT: G_SELF_DESTRUCT + # - G_COLD_ACCOUNT_ACCESS (beneficiary cold access) + gas_costs = fork.gas_costs() + exact_static_gas = ( + gas_costs.G_VERY_LOW + + gas_costs.G_SELF_DESTRUCT + + gas_costs.G_COLD_ACCOUNT_ACCESS + ) + + # subtract one from the exact gas to trigger OOG before state access + oog_gas = ( + exact_static_gas - 1 if oog_before_state_access else exact_static_gas + ) + + # caller_contract: CALL with oog_gas + caller_code = Op.CALL(gas=oog_gas, address=selfdestruct_contract) + caller_contract = pre.deploy_contract(code=caller_code) + + tx = Transaction( + sender=alice, + to=caller_contract, + gas_limit=100_000, + ) + + account_expectations: Dict[Address, BalAccountExpectation | None] = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + caller_contract: BalAccountExpectation.empty(), + selfdestruct_contract: BalAccountExpectation.empty(), + # beneficiary only in BAL if we passed check_gas (state accessed) + beneficiary: None + if oog_before_state_access + else BalAccountExpectation.empty(), + } + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + caller_contract: Account(code=caller_code), + # selfdestruct_contract still exists - SELFDESTRUCT failed + selfdestruct_contract: Account( + balance=1000, code=selfdestruct_code + ), + }, + ) + + def test_bal_storage_write_read_same_frame( pre: Alloc, blockchain_test: BlockchainTestFiller, From 7fc5d4d94fdc970cad2329670696b25b76c142a0 Mon Sep 17 00:00:00 2001 From: Felipe Selmo Date: Fri, 5 Dec 2025 00:11:55 +0000 Subject: [PATCH 41/51] update test_cases.md --- .../amsterdam/eip7928_block_level_access_lists/test_cases.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 06287528ef..9ddca8a362 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -93,6 +93,6 @@ | `test_init_collision_create_tx` | Ensure BAL tracks CREATE collisions correctly (pre-Amsterdam test with BAL) | CREATE transaction targeting address with existing storage aborts | BAL **MUST** show empty expectations for collision address (no changes occur due to abort) | ✅ Completed | | `test_call_to_pre_authorized_oog` | Ensure BAL handles OOG during EIP-7702 delegation access (pre-Amsterdam test with BAL) | Call to delegated account that OOGs before accessing delegation contract | BAL **MUST** include auth_signer (code read for delegation check) but **MUST NOT** include delegation contract (OOG before access) | ✅ Completed | | `test_selfdestruct_created_in_same_tx_with_revert` | Ensure BAL tracks selfdestruct with revert correctly (pre-Amsterdam test with BAL) | Contract created and selfdestructed in same tx with nested revert | BAL **MUST** track storage reads and balance changes for selfdestruct even with reverts | ✅ Completed | -| `test_value_transfer_gas_calculation` | Ensure BAL correctly tracks OOG scenarios for CALL/CALLCODE/DELEGATECALL/STATICCALL (pre-Amsterdam test with BAL) | Nested calls with precise gas limits to test OOG behavior. For CALL with OOG: target account read for `is_account_alive` check. For CALLCODE/DELEGATECALL/STATICCALL with OOG: target account **NOT** read (OOG before state access) | For CALL: target in BAL even with OOG. For CALLCODE/DELEGATECALL/STATICCALL: target **NOT** in BAL when OOG (state access deferred until after gas check) | ✅ Completed | +| `test_value_transfer_gas_calculation` | Ensure BAL correctly tracks OOG scenarios for CALL/CALLCODE/DELEGATECALL/STATICCALL (pre-Amsterdam test with BAL) | Nested calls with precise gas limits to test OOG behavior. For CALL with OOG: target account is read. For CALLCODE/DELEGATECALL/STATICCALL with OOG: target account **NOT** read (OOG before state access) | For CALL: target in BAL even with OOG. For CALLCODE/DELEGATECALL/STATICCALL: target **NOT** in BAL when OOG (state access deferred until after gas check) | ✅ Completed | | `test_bal_cross_block_precompile_state_leak` | Ensure internal EVM state for precompile handling does not leak between blocks | Block 1: Alice calls RIPEMD-160 (0x03) with zero value (RIPEMD-160 must be pre-funded). Block 2: Bob's transaction triggers an exception (stack underflow). | BAL for Block 1 **MUST** include RIPEMD-160. BAL for Block 2 **MUST NOT** include RIPEMD-160 (never accessed in Block 2). Internal state from Parity Touch Bug (EIP-161) handling must be reset between blocks. | ✅ Completed | -| `test_bal_self_destruct_oog` | Ensure BAL does not include SELFDESTRUCT beneficiary when operation fails due to OOG | Alice calls `Caller` contract which CALLs `SelfDestructContract` with limited gas (100). `SelfDestructContract` attempts SELFDESTRUCT to `Beneficiary`. SELFDESTRUCT requires 32600 gas (5000 base + 2600 cold + 25000 new account). | BAL **MUST** include Alice with `nonce_changes`, `Caller` with empty changes, `SelfDestructContract` with empty changes. BAL **MUST NOT** include `Beneficiary` (SELFDESTRUCT failed OOG, CALL reverted, BAL changes rolled back). Contract balance unchanged. | ✅ Completed | +| `test_bal_self_destruct_oog` | Ensure BAL correctly tracks SELFDESTRUCT beneficiary based on gas boundaries | Alice calls `Caller` contract which CALLs `SelfDestructContract` with precisely controlled gas. `SelfDestructContract` attempts SELFDESTRUCT to new account `Beneficiary`. Static gas = G_VERY_LOW + G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS. Parameterized: (1) OOG before state access (gas = static - 1), (2) OOG after state access (gas = static, but insufficient for G_NEW_ACCOUNT). | For OOG before state access: BAL **MUST NOT** include `Beneficiary` (no state access occurred). For OOG after state access: BAL **MUST** include `Beneficiary` with empty changes (state was accessed before G_NEW_ACCOUNT check failed). Both cases: Alice with `nonce_changes`, `Caller` and `SelfDestructContract` with empty changes. Contract balance unchanged. | ✅ Completed | From 14f8d34bf41e127101e4bc906a8a3cf4cf37f918 Mon Sep 17 00:00:00 2001 From: felipe Date: Mon, 1 Dec 2025 23:25:51 +0000 Subject: [PATCH 42/51] feat(tests): Port oog create refund test; add BAL >= Amsterdam --- .../forks/amsterdam/vm/interpreter.py | 28 +- .../test_create_oog_from_eoa_refunds.py | 417 +++++++++++++++ .../CreateOOGFromEOARefundsFiller.yml | 483 ------------------ 3 files changed, 439 insertions(+), 489 deletions(-) create mode 100644 tests/cancun/create/test_create_oog_from_eoa_refunds.py delete mode 100644 tests/static/state_tests/stCreateTest/CreateOOGFromEOARefundsFiller.yml diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 57f890a12e..893a0d8833 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -267,7 +267,7 @@ def process_create_message(message: Message) -> Evm: create_frame = create_child_frame(parent_frame) increment_nonce(state, message.current_target, create_frame) - evm = process_message(message) + evm = process_message(message, parent_state_frame=create_frame) if not evm.error: contract_code = evm.output contract_code_gas = Uint(len(contract_code)) * GAS_CODE_DEPOSIT @@ -296,7 +296,10 @@ def process_create_message(message: Message) -> Evm: return evm -def process_message(message: Message) -> Evm: +def process_message( + message: Message, + parent_state_frame: Optional[StateChanges] = None, +) -> Evm: """ Move ether and execute the relevant code. @@ -304,6 +307,12 @@ def process_message(message: Message) -> Evm: ---------- message : Transaction specific items. + parent_state_frame : + Optional parent frame for state tracking. When provided (e.g., for + CREATE's init code), state changes are tracked as a child of this + frame instead of the default parent determined by the message. + This ensures proper frame hierarchy for CREATE operations where + init code changes must be children of the CREATE frame. Returns ------- @@ -318,8 +327,15 @@ def process_message(message: Message) -> Evm: begin_transaction(state, transient_storage) - parent_frame = get_parent_frame(message) - state_changes = get_message_state_frame(message) + if parent_state_frame is not None: + # Use provided parent for CREATE's init code execution. + # This ensures init code state changes are children of create_frame, + # so they are properly converted to reads if code deposit fails. + parent_changes = parent_state_frame + state_changes = create_child_frame(parent_state_frame) + else: + parent_changes = get_parent_frame(message) + state_changes = get_message_state_frame(message) track_address(state_changes, message.current_target) @@ -335,11 +351,11 @@ def process_message(message: Message) -> Evm: evm = execute_code(message, state_changes) if evm.error: rollback_transaction(state, transient_storage) - if state_changes != parent_frame: + if state_changes != parent_changes: merge_on_failure(evm.state_changes) else: commit_transaction(state, transient_storage) - if state_changes != parent_frame: + if state_changes != parent_changes: merge_on_success(evm.state_changes) return evm diff --git a/tests/cancun/create/test_create_oog_from_eoa_refunds.py b/tests/cancun/create/test_create_oog_from_eoa_refunds.py new file mode 100644 index 0000000000..ee7f14571a --- /dev/null +++ b/tests/cancun/create/test_create_oog_from_eoa_refunds.py @@ -0,0 +1,417 @@ +""" +Tests for CREATE OOG scenarios from EOA refunds. + +Tests that verify refunds are not applied on contract creation +when the creation runs out of gas. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Dict + +import pytest +from execution_testing import ( + Account, + Address, + Alloc, + BalAccountExpectation, + BalNonceChange, + BalStorageChange, + BalStorageSlot, + Block, + BlockAccessListExpectation, + BlockchainTestFiller, + Fork, + Op, + Transaction, + compute_create2_address, + compute_create_address, +) + +pytestmark = pytest.mark.valid_from("Cancun") + + +class OogScenario(Enum): + """Different ways a CREATE can run out of gas or succeed.""" + + NO_OOG = "no_oog" + OOG_CODE_DEPOSIT = "oog_code_deposit" # OOG due to code deposit cost + OOG_INVALID = "oog_invalid_opcode" # OOG due to INVALID opcode + + +class RefundType(Enum): + """Different refund mechanisms tested.""" + + SSTORE_DIRECT = "sstore_in_init_code" + SSTORE_CALL = "sstore_via_call" + SSTORE_DELEGATECALL = "sstore_via_delegatecall" + SSTORE_CALLCODE = "sstore_via_callcode" + SELFDESTRUCT = "selfdestruct_via_call" + LOG_OP = "log_operations" + NESTED_CREATE = "nested_create_in_init_code" + NESTED_CREATE2 = "nested_create2_in_init_code" + + +@dataclass +class HelperContracts: + """Container for deployed helper contract addresses.""" + + sstore_refund: Address + selfdestruct: Address + log_op: Address + init_code: Address + + +def deploy_helper_contracts(pre: Alloc) -> HelperContracts: + """Deploy all helper contracts needed for the tests.""" + # Simple contract to reset sstore and get refund: sstore(1, 0) + sstore_refund_code = Op.SSTORE(1, 0) + Op.STOP + sstore_refund = pre.deploy_contract( + code=sstore_refund_code, + storage={1: 1}, + ) + + # Simple contract that self-destructs to refund + selfdestruct_code = Op.SELFDESTRUCT(Op.ORIGIN) + Op.STOP + selfdestruct = pre.deploy_contract( + code=selfdestruct_code, + storage={1: 1}, + ) + + # Simple contract that performs log operations + log_op_code = ( + Op.MSTORE(0, 0xFF) + + Op.LOG0(0, 32) + + Op.LOG1(0, 32, 0xFA) + + Op.LOG2(0, 32, 0xFA, 0xFB) + + Op.LOG3(0, 32, 0xFA, 0xFB, 0xFC) + + Op.LOG4(0, 32, 0xFA, 0xFB, 0xFC, 0xFD) + + Op.STOP + ) + log_op = pre.deploy_contract( + code=log_op_code, + storage={1: 1}, + ) + + # Init code that successfully creates contract but contains a refund + # sstore(0, 1); sstore(0, 0); return(0, 1) + init_code_with_refund = Op.SSTORE(0, 1) + Op.SSTORE(0, 0) + Op.RETURN(0, 1) + init_code = pre.deploy_contract( + code=init_code_with_refund, + ) + + return HelperContracts( + sstore_refund=sstore_refund, + selfdestruct=selfdestruct, + log_op=log_op, + init_code=init_code, + ) + + +def build_init_code( + refund_type: RefundType, + oog_scenario: OogScenario, + helpers: HelperContracts, +) -> bytes: + """ + Build init code based on refund type and OOG scenario. + + All init codes: + - Write to storage slot 0 + - Optionally trigger refund mechanism + - End with either small return (success) or large return/INVALID (OOG) + """ + # Common prefix: sstore(0, 1) to mark storage access + prefix = Op.SSTORE(0, 1) + + # Build the refund-triggering portion based on type + if refund_type == RefundType.SSTORE_DIRECT: + # Direct sstore refund: sstore(1, 1); sstore(1, 0) + refund_code = Op.SSTORE(1, 1) + Op.SSTORE(1, 0) + + elif refund_type == RefundType.SSTORE_CALL: + # Call to sstore refund helper + refund_code = Op.POP( + Op.CALL(Op.GAS, helpers.sstore_refund, 0, 0, 0, 0, 0) + ) + + elif refund_type == RefundType.SSTORE_DELEGATECALL: + # Delegatecall to sstore refund helper (needs local storage setup) + refund_code = Op.SSTORE(1, 1) + Op.POP( + Op.DELEGATECALL(Op.GAS, helpers.sstore_refund, 0, 0, 0, 0) + ) + + elif refund_type == RefundType.SSTORE_CALLCODE: + refund_code = Op.SSTORE(1, 1) + Op.POP( + Op.CALLCODE(Op.GAS, helpers.sstore_refund, 0, 0, 0, 0, 0) + ) + + elif refund_type == RefundType.SELFDESTRUCT: + refund_code = Op.POP( + Op.CALL(Op.GAS, helpers.selfdestruct, 0, 0, 0, 0, 0) + ) + + elif refund_type == RefundType.LOG_OP: + # call to log op helper + refund_code = Op.POP(Op.CALL(Op.GAS, helpers.log_op, 0, 0, 0, 0, 0)) + + elif refund_type == RefundType.NESTED_CREATE: + # Nested CREATE with refund in init code + # extcodecopy the init code helper and CREATE from it + refund_code = ( + Op.SSTORE(1, 1) + + Op.SSTORE(1, 0) + + Op.EXTCODECOPY( + helpers.init_code, 0, 0, Op.EXTCODESIZE(helpers.init_code) + ) + + Op.POP(Op.CREATE(0, 0, Op.EXTCODESIZE(helpers.init_code))) + ) + + elif refund_type == RefundType.NESTED_CREATE2: + # Nested CREATE2 with refund in init code + refund_code = ( + Op.SSTORE(1, 1) + + Op.SSTORE(1, 0) + + Op.EXTCODECOPY( + helpers.init_code, 0, 0, Op.EXTCODESIZE(helpers.init_code) + ) + + Op.POP(Op.CREATE2(0, 0, Op.EXTCODESIZE(helpers.init_code), 0)) + ) + else: + refund_code = Op.STOP + + # Build the ending based on OOG scenario + if oog_scenario == OogScenario.NO_OOG: + # Return 1 byte of code (cheap code deposit) + if refund_type in ( + RefundType.NESTED_CREATE, + RefundType.NESTED_CREATE2, + ): + # For nested creates, return after init code length + ending = Op.RETURN(Op.ADD(Op.EXTCODESIZE(helpers.init_code), 1), 1) + else: + ending = Op.RETURN(0, 1) + + elif oog_scenario == OogScenario.OOG_CODE_DEPOSIT: + # Return 5000 bytes of code - code deposit cost exceeds available gas + if refund_type in ( + RefundType.NESTED_CREATE, + RefundType.NESTED_CREATE2, + ): + ending = Op.RETURN( + Op.ADD(Op.EXTCODESIZE(helpers.init_code), 1), 5000 + ) + else: + ending = Op.RETURN(0, 5000) + + elif oog_scenario == OogScenario.OOG_INVALID: + # INVALID opcode causes OOG (all gas consumed, no refund) + ending = Op.INVALID + + else: + ending = Op.STOP + + return bytes(prefix + refund_code + ending) + + +@pytest.mark.parametrize( + "oog_scenario", + [ + pytest.param(OogScenario.NO_OOG, id="no_oog"), + pytest.param(OogScenario.OOG_CODE_DEPOSIT, id="oog_code_deposit"), + pytest.param(OogScenario.OOG_INVALID, id="oog_invalid_opcode"), + ], +) +@pytest.mark.parametrize( + "refund_type", + [ + pytest.param(RefundType.SSTORE_DIRECT, id="sstore_direct"), + pytest.param(RefundType.SSTORE_CALL, id="sstore_call"), + pytest.param(RefundType.SSTORE_DELEGATECALL, id="sstore_delegatecall"), + pytest.param(RefundType.SSTORE_CALLCODE, id="sstore_callcode"), + pytest.param(RefundType.SELFDESTRUCT, id="selfdestruct"), + pytest.param(RefundType.LOG_OP, id="log_op"), + pytest.param(RefundType.NESTED_CREATE, id="nested_create"), + pytest.param(RefundType.NESTED_CREATE2, id="nested_create2"), + ], +) +@pytest.mark.ported_from( + [ + "https://github.com/ethereum/tests/blob/v13.3/src/GeneralStateTestsFiller/stCreateTest/CreateOOGFromEOARefundsFiller.yml", + ], + pr=["https://github.com/ethereum/execution-specs/pull/1831"], +) +def test_create_oog_from_eoa_refunds( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + refund_type: RefundType, + oog_scenario: OogScenario, + fork: Fork, +) -> None: + """ + Test CREATE from EOA with various refund mechanisms and OOG scenarios. + + Verifies that: + 1. Refunds are not applied when contract creation runs Out of Gas + 2. When OOG occurs, the sender's balance is fully consumed (no refund) + 3. When OOG occurs, the contract is not created + + For BAL (Block Access List) tracking: + - NoOoG: Storage writes should be recorded as `storage_changes` + - OoG: Storage writes should be converted to `storage_reads` since + the CREATE failed and all state changes were reverted + """ + helpers = deploy_helper_contracts(pre) + sender = pre.fund_eoa(amount=4_000_000) + init_code = build_init_code(refund_type, oog_scenario, helpers) + created_address = compute_create_address(address=sender, nonce=0) + + tx = Transaction( + sender=sender, + to=None, + data=init_code, + gas_limit=400_000, + ) + + post: Dict[Address, Account | None] = { + sender: Account(nonce=1), + } + + if oog_scenario == OogScenario.NO_OOG: + # contract created with code 0x00 (1 byte from memory) + if refund_type == RefundType.NESTED_CREATE: + # Nested CREATE increments the created contract's nonce to 2 + post[created_address] = Account( + nonce=2, + code=b"\x00", + storage={0: 1}, # successful write + ) + + nested_created = compute_create_address( + address=created_address, nonce=1 + ) + post[nested_created] = Account( + nonce=1, + code=b"\x00", + storage={}, + ) + elif refund_type == RefundType.NESTED_CREATE2: + # nested create2 increments the created contract's nonce to 2 + post[created_address] = Account( + nonce=2, + code=b"\x00", + storage={0: 1}, + ) + + nested_created = compute_create2_address( + address=created_address, + salt=0, + initcode=Op.SSTORE(0, 1) + Op.SSTORE(0, 0) + Op.RETURN(0, 1), + ) + post[nested_created] = Account( + nonce=1, + code=b"\x00", + storage={}, + ) + else: + post[created_address] = Account( + nonce=1, + code=b"\x00", + storage={0: 1}, + ) + post[sender] = Account(nonce=1) + else: + # OOG case: contract not created, sender balance is fully consumed + post[created_address] = Account.NONEXISTENT + post[sender] = Account( + nonce=1, + balance=0, + ) + + if refund_type == RefundType.SELFDESTRUCT: + selfdestruct_code = Op.SELFDESTRUCT(Op.ORIGIN) + Op.STOP + if oog_scenario == OogScenario.NO_OOG: + # selfdestruct succeeded, balance is 0 + post[helpers.selfdestruct] = Account( + balance=0, + nonce=1, + ) + else: + # OOG: selfdestruct reverted, helper unchanged + post[helpers.selfdestruct] = Account( + code=bytes(selfdestruct_code), + nonce=1, + storage={1: 1}, + ) + + bal_expectation = None + if fork.header_bal_hash_required(): + if oog_scenario == OogScenario.NO_OOG: + # Success: storage write to slot 0 persists + expected_nonce = ( + 2 + if refund_type + in (RefundType.NESTED_CREATE, RefundType.NESTED_CREATE2) + else 1 + ) + created_bal = BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=1, post_nonce=expected_nonce) + ], + storage_changes=[ + BalStorageSlot( + slot=0, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=1) + ], + ), + ], + storage_reads=( + # noop write 0 -> 1 -> 0 + [1] + if refund_type + in ( + RefundType.SSTORE_DIRECT, + RefundType.SSTORE_DELEGATECALL, + RefundType.SSTORE_CALLCODE, + RefundType.NESTED_CREATE, + RefundType.NESTED_CREATE2, + ) + else [] + ), + ) + else: + # OOG case: storage writes converted to reads + # All refund types write to slot 0, most also write to slot 1 + if refund_type in ( + RefundType.SSTORE_DIRECT, + RefundType.SSTORE_DELEGATECALL, + RefundType.SSTORE_CALLCODE, + RefundType.NESTED_CREATE, + RefundType.NESTED_CREATE2, + ): + # write to both slot 0 and slot 1 (noop write 0 -> 1 -> 0) + created_bal = BalAccountExpectation( + storage_changes=[], + storage_reads=[0, 1], + ) + else: + # SSTORE_CALL, SELFDESTRUCT, LOG_OP only write to slot 0 + created_bal = BalAccountExpectation( + storage_changes=[], + storage_reads=[0], + ) + bal_expectation = BlockAccessListExpectation( + account_expectations={ + sender: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + created_address: created_bal, + } + ) + + blockchain_test( + pre=pre, + blocks=[Block(txs=[tx], expected_block_access_list=bal_expectation)], + post=post, + ) diff --git a/tests/static/state_tests/stCreateTest/CreateOOGFromEOARefundsFiller.yml b/tests/static/state_tests/stCreateTest/CreateOOGFromEOARefundsFiller.yml deleted file mode 100644 index 4ce14cc9fe..0000000000 --- a/tests/static/state_tests/stCreateTest/CreateOOGFromEOARefundsFiller.yml +++ /dev/null @@ -1,483 +0,0 @@ -CreateOOGFromEOARefunds: - # Test that verifies the refunds are not applied on contract creation when the creation runs Out of Gas - env: - currentCoinbase: 2adc25665018aa1fe0e6bc666dac8fc2697ff9ba - currentDifficulty: '0x20000' - currentGasLimit: 0x100000000 - currentNumber: "1" - currentTimestamp: "1000" - - pre: - #### MAIN CALLER - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - balance: '0x3d0900' - code: '0x' - nonce: '1' - storage: {} - - ### HELPER CONTRACTS - # Simple contract to reset sstore and refund - 00000000000000000000000000000000000c0deA: - balance: '0' - code: | - :yul berlin - { - // Simple SSTORE to zero to get a refund - sstore(1, 0) - } - nonce: '1' - storage: { - '1': '1' - } - - # Simple contract that self-destructs to refund - 00000000000000000000000000000000000c0deD: - balance: '0' - code: | - :yul berlin - { - selfdestruct(origin()) - } - nonce: '1' - storage: { - '1': '1' - } - - # Simple contract that performs log operations - 00000000000000000000000000000000000c0de0: - balance: '0' - code: | - :yul berlin - { - mstore(0, 0xff) - log0(0, 32) - log1(0, 32, 0xfa) - log2(0, 32, 0xfa, 0xfb) - log3(0, 32, 0xfa, 0xfb, 0xfc) - log4(0, 32, 0xfa, 0xfb, 0xfc, 0xfd) - } - nonce: '1' - storage: { - '1': '1' - } - - # Init code that successfully creates contract but contains a refund - 00000000000000000000000000000000000c0de1: - balance: '0' - code: | - :yul berlin - { - sstore(0, 1) - sstore(0, 0) - return(0, 1) - } - nonce: '1' - storage: {} - - - transaction: - data: - # Create from EOA, Sstore Refund in Init Code, no OoG - - :label SStore_Refund_NoOoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - return(0, 1) - } - - # Create from EOA, Sstore Refund in Init Code, OoG on Code Deposit - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - return(0, 5000) - } - - # Create from EOA, Sstore Refund in Init Code, OoG on Invalid opcode - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - invalid() - } - - # Create from EOA, Sstore Refund in Call, no OoG - - :label SStore_Refund_NoOoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - return(0, 1) - } - - # Create from EOA, Sstore Refund in Call, OoG on Code Deposit - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - return(0, 5000) - } - - # Create from EOA, Sstore Refund in Call, OoG on Invalid opcode - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - invalid() - } - - # Create from EOA, Sstore Refund in DelegateCall, no OoG - - :label SStore_Refund_NoOoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(delegatecall(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0)) - return(0, 1) - } - - # Create from EOA, Sstore Refund in DelegateCall, OoG on Code Deposit - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(delegatecall(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0)) - return(0, 5000) - } - - # Create from EOA, Sstore Refund in DelegateCall, OoG on Invalid opcode - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(delegatecall(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0)) - invalid() - } - - # Create from EOA, Sstore Refund in CallCode, no OoG - - :label SStore_Refund_NoOoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(callcode(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - return(0, 1) - } - - # Create from EOA, Sstore Refund in CallCode, OoG on Code Deposit - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(callcode(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - return(0, 5000) - } - - # Create from EOA, Sstore Refund in CallCode, OoG on Invalid opcode - - :label SStore_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - pop(callcode(gas(), 0x00000000000000000000000000000000000c0deA, 0, 0, 0, 0, 0)) - invalid() - } - - # Create from EOA, Refund Self-destruct call, no OoG - - :label SelfDestruct_Refund_NoOoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deD, 0, 0, 0, 0, 0)) - return(0, 1) - } - - # Create from EOA, Refund Self-destruct call, OoG on Code Deposit - - :label SelfDestruct_Refund_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deD, 0, 0, 0, 0, 0)) - return(0, 5000) - } - - # Create from EOA, Refund Self-destruct call, OoG on Invalid opcode - - :label SelfDestruct_Refund_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0deD, 0, 0, 0, 0, 0)) - invalid() - } - - # Create from EOA, Log operation in call, no OoG - - :label LogOp_NoOoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0de0, 0, 0, 0, 0, 0)) - return(0, 1) - } - - # Create from EOA, Log operation in call, OoG on Code Deposit - - :label LogOp_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0de0, 0, 0, 0, 0, 0)) - return(0, 5000) - } - - # Create from EOA, Log operation in call, OoG on Invalid opcode - - :label LogOp_OoG :yul berlin { - sstore(0, 1) - pop(call(gas(), 0x00000000000000000000000000000000000c0de0, 0, 0, 0, 0, 0)) - invalid() - } - - # Create from EOA, Refund within CREATE, no OoG - - :label SStore_Create_Refund_NoOoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - let initcodelength := extcodesize(initcodeaddr) - extcodecopy(initcodeaddr, 0, 0, initcodelength) - pop(create(0, 0, initcodelength)) - return(add(initcodelength, 1), 1) - } - - # Create from EOA, Refund within CREATE, OoG on Code Deposit - - :label SStore_Create_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - let initcodelength := extcodesize(initcodeaddr) - extcodecopy(initcodeaddr, 0, 0, initcodelength) - pop(create(0, 0, initcodelength)) - return(add(initcodelength, 1), 5000) - } - - # Create from EOA, Refund within CREATE, OoG on Invalid opcode - - :label SStore_Create_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - let initcodelength := extcodesize(initcodeaddr) - extcodecopy(initcodeaddr, 0, 0, initcodelength) - pop(create(0, 0, initcodelength)) - invalid() - } - - # Create2 from EOA, Refund within CREATE, no OoG - - :label SStore_Create2_Refund_NoOoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - //let initcodelength := extcodesize(initcodeaddr) - //extcodecopy(initcodeaddr, 0, 0, initcodelength) - - //protection from solc version changing the init code - - let initcodelength := 15 - mstore(0, 0x6001600055600060005560016000f30000000000000000000000000000000000) - - pop(create2(0, 0, initcodelength, 0)) - return(add(initcodelength, 1), 1) - } - - # Create2 from EOA, Refund within CREATE, OoG on Code Deposit - - :label SStore_Create2_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - let initcodelength := extcodesize(initcodeaddr) - extcodecopy(initcodeaddr, 0, 0, initcodelength) - pop(create2(0, 0, initcodelength, 0)) - return(add(initcodelength, 1), 5000) - } - - # Create2 from EOA, Refund within CREATE, OoG on Invalid opcode - - :label SStore_Create2_Refund_OoG :yul berlin { - sstore(0, 1) - sstore(1, 1) - sstore(1, 0) - let initcodeaddr := 0x00000000000000000000000000000000000c0de1 - let initcodelength := extcodesize(initcodeaddr) - extcodecopy(initcodeaddr, 0, 0, initcodelength) - pop(create2(0, 0, initcodelength, 0)) - invalid() - } - - gasLimit: - - 0x61a80 - gasPrice: '10' - nonce: '1' - to: "" - secretKey: "45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" - value: - - 0 - - expect: - - - indexes: - data: - - :label SStore_Refund_NoOoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - nonce: 1 - code: '0x00' - storage: { - '0': 1 - } - - indexes: - data: - - :label SStore_Refund_OoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - # When we OoG, we use up all the gas regardless of the refunds - balance: 0 - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - shouldnotexist: 1 - - - - indexes: - data: - - :label SelfDestruct_Refund_NoOoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - nonce: 1 - code: '0x00' - storage: { - '0': 1 - } - 00000000000000000000000000000000000c0deD: - balance: 0 - nonce: 1 - - - indexes: - data: - - :label SelfDestruct_Refund_OoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - # When we OoG, we use up all the gas regardless of the refunds - balance: 0 - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - shouldnotexist: 1 - - 00000000000000000000000000000000000c0deD: - code: '0x32FF' - nonce: '1' - storage: { - '1': '1' - } - - - indexes: - data: - - :label LogOp_NoOoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - nonce: 1 - code: '0x00' - storage: { - '0': 1 - } - - indexes: - data: - - :label LogOp_OoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - # When we OoG, we use up all the gas regardless of the refunds - balance: 0 - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - shouldnotexist: 1 - - - indexes: - data: - - :label SStore_Create_Refund_NoOoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - nonce: 2 - code: '0x00' - storage: { - '0': 1 - } - e3476106159f87477ad639e3ddcbb6b240efe459: - nonce: 1 - code: '0x00' - storage: {} - - - indexes: - data: - - :label SStore_Create_Refund_OoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - # When we OoG, we use up all the gas regardless of the refunds - balance: 0 - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - shouldnotexist: 1 - e3476106159f87477ad639e3ddcbb6b240efe459: - shouldnotexist: 1 - - - indexes: - data: - - :label SStore_Create2_Refund_NoOoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - nonce: 2 - code: '0x00' - storage: { - '0': 1 - } - 1eeb9ca3824a07c140fc01aa562a3a896f44e790: - nonce: 1 - code: '0x00' - storage: {} - - - indexes: - data: - - :label SStore_Create2_Refund_OoG - network: - - '>=Cancun' - result: - - a94f5374fce5edbc8e2a8697c15331677e6ebf0b: - # When we OoG, we use up all the gas regardless of the refunds - balance: 0 - nonce: 2 - - ec0e71ad0a90ffe1909d27dac207f7680abba42d: - shouldnotexist: 1 - 1eeb9ca3824a07c140fc01aa562a3a896f44e790: - shouldnotexist: 1 From 402d813c467e5a420b91c02adbb51357ee04367d Mon Sep 17 00:00:00 2001 From: felipe Date: Mon, 8 Dec 2025 12:38:20 -0700 Subject: [PATCH 43/51] refactor(spec-specs): Refactor state changes and frame hierarchy (#1841) * refactor(spec-specs): Refactor state changes and their frames * chore(spec-specs): cleanup BAL logic; organize gas check for SSTORE * refactor(spec-specs): Changes from comments on PR #1841 * enhance: don't set defaults for state_changes --- src/ethereum/forks/amsterdam/fork.py | 116 ++--- src/ethereum/forks/amsterdam/state.py | 110 +--- src/ethereum/forks/amsterdam/state_tracker.py | 477 ++++++++---------- src/ethereum/forks/amsterdam/utils/message.py | 11 +- src/ethereum/forks/amsterdam/vm/__init__.py | 8 +- .../forks/amsterdam/vm/eoa_delegation.py | 35 +- .../amsterdam/vm/instructions/storage.py | 75 +-- .../forks/amsterdam/vm/instructions/system.py | 85 +++- .../forks/amsterdam/vm/interpreter.py | 163 +++--- .../evm_tools/t8n/__init__.py | 13 +- 10 files changed, 490 insertions(+), 603 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 358f194f6e..697086e2b4 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -30,7 +30,6 @@ from . import vm from .block_access_lists.builder import build_block_access_list -from .block_access_lists.rlp_types import BlockAccessIndex from .block_access_lists.rlp_utils import compute_block_access_list_hash from .blocks import Block, Header, Log, Receipt, Withdrawal, encode_receipt from .bloom import logs_bloom @@ -65,16 +64,16 @@ state_root, ) from .state_tracker import ( + StateChanges, capture_pre_balance, commit_transaction_frame, create_child_frame, - get_block_access_index, - handle_in_transaction_selfdestruct, + filter_net_zero_frame_changes, increment_block_access_index, - merge_on_success, - normalize_balance_changes_for_transaction, track_address, track_balance_change, + track_nonce_change, + track_selfdestruct, ) from .transactions import ( AccessListTransaction, @@ -249,6 +248,7 @@ def state_transition(chain: BlockChain, block: Block) -> None: prev_randao=block.header.prev_randao, excess_blob_gas=block.header.excess_blob_gas, parent_beacon_block_root=block.header.parent_beacon_block_root, + state_changes=StateChanges(), ) block_output = apply_body( @@ -636,7 +636,7 @@ def process_system_transaction( """ # EIP-7928: Create a child frame for system transaction # This allows proper pre-state capture for net-zero filtering - system_tx_state_changes = create_child_frame(block_env.block_state_changes) + system_tx_state_changes = create_child_frame(block_env.state_changes) tx_env = vm.TransactionEnvironment( origin=SYSTEM_ADDRESS, @@ -649,8 +649,12 @@ def process_system_transaction( authorizations=(), index_in_block=None, tx_hash=None, + state_changes=system_tx_state_changes, ) + # Create call frame as child of tx frame + call_frame = create_child_frame(tx_env.state_changes) + system_tx_message = Message( block_env=block_env, tx_env=tx_env, @@ -669,14 +673,15 @@ def process_system_transaction( accessed_storage_keys=set(), disable_precompiles=False, parent_evm=None, - transaction_state_changes=system_tx_state_changes, + is_create=False, + state_changes=call_frame, ) system_tx_output = process_message_call(system_tx_message) - # Merge system transaction changes back to block frame + # Commit system transaction changes to block frame # System transactions always succeed (or block is invalid) - merge_on_success(system_tx_state_changes) + commit_transaction_frame(tx_env.state_changes) return system_tx_output @@ -816,7 +821,7 @@ def apply_body( # EIP-7928: Increment block frame to post-execution index # After N transactions, block frame is at index N # Post-execution operations (withdrawals, etc.) use index N+1 - increment_block_access_index(block_env.block_state_changes) + increment_block_access_index(block_env.state_changes) process_withdrawals(block_env, block_output, withdrawals) @@ -824,9 +829,9 @@ def apply_body( block_env=block_env, block_output=block_output, ) - # Build block access list from block_env.block_state_changes + # Build block access list from block_env.state_changes block_output.block_access_list = build_block_access_list( - block_env.block_state_changes + block_env.state_changes ) return block_output @@ -909,9 +914,10 @@ def process_transaction( """ # EIP-7928: Create a transaction-level StateChanges frame # The frame will read the current block_access_index from the block frame - increment_block_access_index(block_env.block_state_changes) - tx_state_changes = create_child_frame(block_env.block_state_changes) + increment_block_access_index(block_env.state_changes) + tx_state_changes = create_child_frame(block_env.state_changes) + # Capture coinbase pre-balance for net-zero filtering coinbase_pre_balance = get_account( block_env.state, block_env.coinbase ).balance @@ -949,16 +955,27 @@ def process_transaction( effective_gas_fee = tx.gas * effective_gas_price gas = tx.gas - intrinsic_gas - increment_nonce(block_env.state, sender, tx_state_changes) + + # Track sender nonce increment + increment_nonce(block_env.state, sender) + sender_nonce_after = get_account(block_env.state, sender).nonce + track_nonce_change(tx_state_changes, sender, U64(sender_nonce_after)) + + # Track sender balance deduction for gas fee + sender_balance_before = get_account(block_env.state, sender).balance + track_address(tx_state_changes, sender) + capture_pre_balance(tx_state_changes, sender, sender_balance_before) sender_balance_after_gas_fee = ( Uint(sender_account.balance) - effective_gas_fee - blob_gas_fee ) set_account_balance( - block_env.state, + block_env.state, sender, U256(sender_balance_after_gas_fee) + ) + track_balance_change( + tx_state_changes, sender, U256(sender_balance_after_gas_fee), - tx_state_changes, ) access_list_addresses = set() @@ -993,13 +1010,13 @@ def process_transaction( authorizations=authorizations, index_in_block=index, tx_hash=get_transaction_hash(encode_transaction(tx)), + state_changes=tx_state_changes, ) message = prepare_message( block_env, tx_env, tx, - tx_state_changes, ) tx_output = process_message_call(message) @@ -1029,11 +1046,11 @@ def process_transaction( sender_balance_after_refund = get_account( block_env.state, sender ).balance + U256(gas_refund_amount) - set_account_balance( - block_env.state, + set_account_balance(block_env.state, sender, sender_balance_after_refund) + track_balance_change( + tx_env.state_changes, sender, sender_balance_after_refund, - tx_state_changes, ) coinbase_balance_after_mining_fee = get_account( @@ -1041,10 +1058,12 @@ def process_transaction( ).balance + U256(transaction_fee) set_account_balance( - block_env.state, + block_env.state, block_env.coinbase, coinbase_balance_after_mining_fee + ) + track_balance_change( + tx_env.state_changes, block_env.coinbase, coinbase_balance_after_mining_fee, - tx_state_changes, ) if coinbase_balance_after_mining_fee == 0 and account_exists_and_is_empty( @@ -1070,35 +1089,19 @@ def process_transaction( block_output.block_logs += tx_output.logs - # EIP-7928: Handle in-transaction self-destruct BEFORE normalization - # Destroy accounts first so normalization sees correct post-tx state - # Only accounts created in same tx are in accounts_to_delete per EIP-6780 for address in tx_output.accounts_to_delete: destroy_account(block_env.state, address) - # EIP-7928: Normalize balance changes for this transaction before merging - # into block frame. Must happen AFTER destroy_account so net-zero filtering - # sees the correct post-transaction balance (0 for destroyed accounts). - normalize_balance_changes_for_transaction( - tx_state_changes, - BlockAccessIndex( - get_block_access_index(block_env.block_state_changes) - ), - block_env.state, - ) + # EIP-7928: Filter net-zero changes before committing to block frame. + # Must happen AFTER destroy_account so filtering sees correct state. + filter_net_zero_frame_changes(tx_env.state_changes, block_env.state) - commit_transaction_frame(tx_state_changes) + commit_transaction_frame(tx_env.state_changes) - # EIP-7928: Handle in-transaction self-destruct normalization AFTER merge + # EIP-7928: Track in-transaction self-destruct normalization AFTER merge # Convert storage writes to reads and remove nonce/code changes for address in tx_output.accounts_to_delete: - handle_in_transaction_selfdestruct( - block_env.block_state_changes, - address, - BlockAccessIndex( - get_block_access_index(block_env.block_state_changes) - ), - ) + track_selfdestruct(block_env.state_changes, address) def process_withdrawals( @@ -1109,13 +1112,12 @@ def process_withdrawals( """ Increase the balance of the withdrawing account. """ + # Capture pre-state for withdrawal balance filtering withdrawal_addresses = {wd.address for wd in withdrawals} for address in withdrawal_addresses: pre_balance = get_account(block_env.state, address).balance - track_address(block_env.block_state_changes, address) - capture_pre_balance( - block_env.block_state_changes, address, pre_balance - ) + track_address(block_env.state_changes, address) + capture_pre_balance(block_env.state_changes, address, pre_balance) def increase_recipient_balance(recipient: Account) -> None: recipient.balance += wd.amount * U256(10**9) @@ -1131,22 +1133,16 @@ def increase_recipient_balance(recipient: Account) -> None: new_balance = get_account(block_env.state, wd.address).balance track_balance_change( - block_env.block_state_changes, wd.address, new_balance + block_env.state_changes, + wd.address, + new_balance, ) if account_exists_and_is_empty(block_env.state, wd.address): destroy_account(block_env.state, wd.address) - # EIP-7928: Normalize balance changes after all withdrawals - # Filters out net-zero changes - - normalize_balance_changes_for_transaction( - block_env.block_state_changes, - BlockAccessIndex( - get_block_access_index(block_env.block_state_changes) - ), - block_env.state, - ) + # EIP-7928: Filter net-zero balance changes for withdrawals + filter_net_zero_frame_changes(block_env.state_changes, block_env.state) def check_gas_limit(gas_limit: Uint, parent_gas_limit: Uint) -> bool: diff --git a/src/ethereum/forks/amsterdam/state.py b/src/ethereum/forks/amsterdam/state.py index c1d331942a..fcf12e971b 100644 --- a/src/ethereum/forks/amsterdam/state.py +++ b/src/ethereum/forks/amsterdam/state.py @@ -21,17 +21,9 @@ from ethereum_types.bytes import Bytes, Bytes32 from ethereum_types.frozen import modify -from ethereum_types.numeric import U64, U256, Uint +from ethereum_types.numeric import U256, Uint from .fork_types import EMPTY_ACCOUNT, Account, Address, Root -from .state_tracker import ( - StateChanges, - capture_pre_balance, - track_address, - track_balance_change, - track_code_change, - track_nonce_change, -) from .trie import EMPTY_TRIE_ROOT, Trie, copy_trie, root, trie_get, trie_set if TYPE_CHECKING: @@ -517,18 +509,22 @@ def move_ether( sender_address: Address, recipient_address: Address, amount: U256, - state_changes: StateChanges, ) -> None: """ Move funds between accounts. - """ - sender_balance = get_account(state, sender_address).balance - recipient_balance = get_account(state, recipient_address).balance - track_address(state_changes, sender_address) - capture_pre_balance(state_changes, sender_address, sender_balance) - track_address(state_changes, recipient_address) - capture_pre_balance(state_changes, recipient_address, recipient_balance) + Parameters + ---------- + state: + The current state. + sender_address: + Address of the sender. + recipient_address: + Address of the recipient. + amount: + The amount to transfer. + + """ def reduce_sender_balance(sender: Account) -> None: if sender.balance < amount: @@ -541,23 +537,8 @@ def increase_recipient_balance(recipient: Account) -> None: modify_state(state, sender_address, reduce_sender_balance) modify_state(state, recipient_address, increase_recipient_balance) - sender_new_balance = get_account(state, sender_address).balance - recipient_new_balance = get_account(state, recipient_address).balance - - track_balance_change( - state_changes, sender_address, U256(sender_new_balance) - ) - track_balance_change( - state_changes, recipient_address, U256(recipient_new_balance) - ) - -def set_account_balance( - state: State, - address: Address, - amount: U256, - state_changes: StateChanges, -) -> None: +def set_account_balance(state: State, address: Address, amount: U256) -> None: """ Sets the balance of an account. @@ -567,32 +548,20 @@ def set_account_balance( The current state. address: - Address of the account whose nonce needs to be incremented. + Address of the account whose balance needs to be set. amount: The amount that needs to set in balance. - state_changes: - State changes frame for tracking (EIP-7928). - """ - current_balance = get_account(state, address).balance - - track_address(state_changes, address) - capture_pre_balance(state_changes, address, current_balance) def set_balance(account: Account) -> None: account.balance = amount modify_state(state, address, set_balance) - track_balance_change(state_changes, address, amount) -def increment_nonce( - state: State, - address: Address, - state_changes: "StateChanges", -) -> None: +def increment_nonce(state: State, address: Address) -> None: """ Increments the nonce of an account. @@ -604,9 +573,6 @@ def increment_nonce( address: Address of the account whose nonce needs to be incremented. - state_changes: - State changes frame for tracking (EIP-7928). - """ def increase_nonce(sender: Account) -> None: @@ -614,16 +580,8 @@ def increase_nonce(sender: Account) -> None: modify_state(state, address, increase_nonce) - account = get_account(state, address) - track_nonce_change(state_changes, address, U64(account.nonce)) - -def set_code( - state: State, - address: Address, - code: Bytes, - state_changes: StateChanges, -) -> None: +def set_code(state: State, address: Address, code: Bytes) -> None: """ Sets Account code. @@ -638,9 +596,6 @@ def set_code( code: The bytecode that needs to be set. - state_changes: - State changes frame for tracking (EIP-7928). - """ def write_code(sender: Account) -> None: @@ -648,27 +603,13 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) - # Only track code change if it's not net-zero within this frame - # Compare against pre-code captured in this frame, default to b"" - pre_code = state_changes.pre_code.get(address, b"") - if pre_code != code: - track_code_change(state_changes, address, code) - -def set_authority_code( - state: State, - address: Address, - code: Bytes, - state_changes: StateChanges, - current_code: Bytes, -) -> None: +def set_authority_code(state: State, address: Address, code: Bytes) -> None: """ Sets authority account code for EIP-7702 delegation. This function is used specifically for setting authority code within - EIP-7702 Set Code Transactions. Unlike set_code(), it tracks changes based - on the current code rather than pre_code to handle multiple authorizations - to the same address within a single transaction correctly. + EIP-7702 Set Code Transactions. Parameters ---------- @@ -681,13 +622,6 @@ def set_authority_code( code: The delegation designation bytecode to set. - state_changes: - State changes frame for tracking (EIP-7928). - - current_code: - The current code before this change. Used to determine if tracking - is needed (only track if code actually changes from current value). - """ def write_code(sender: Account) -> None: @@ -695,12 +629,6 @@ def write_code(sender: Account) -> None: modify_state(state, address, write_code) - # Only track if code is actually changing from current value - # This allows multiple auths to same address to be tracked individually - # Net-zero filtering happens in commit_transaction_frame - if current_code != code: - track_code_change(state_changes, address, code) - def get_storage_original(state: State, address: Address, key: Bytes32) -> U256: """ diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 7b98396318..19a929d0dd 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -1,21 +1,12 @@ """ -Hierarchical state change tracking for EIP-7928 Block Access Lists. +EIP-7928 Block Access Lists: Hierarchical State Change Tracking. -Implements a frame-based hierarchy: Block → Transaction → Call frames. -Each frame tracks state changes and merges upward on completion: -- Success: merge all changes (reads + writes) -- Failure: merge only reads (writes discarded) +Frame hierarchy mirrors EVM execution: Block -> Transaction -> Call frames. +Each frame tracks state accesses and merges to parent on completion. -Frame Hierarchy: - Block Frame: Root, lifetime = entire block, index 0..N+1 - Transaction Frame: Child of block, lifetime = single transaction - Call Frame: Child of transaction/call, lifetime = single message - -Block Access Index: 0=pre-exec, 1..N=transactions, N+1=post-exec -Stored in root frame, passed explicitly to operations. - -Pre-State Tracking: Values captured before modifications to enable -net-zero filtering. +On success, changes merge upward with net-zero filtering (pre-state vs final). +On failure, only reads merge (writes discarded). Pre-state captures use +first-write-wins semantics and are stored at the transaction frame level. [EIP-7928]: https://eips.ethereum.org/EIPS/eip-7928 """ @@ -38,12 +29,13 @@ class StateChanges: """ Tracks state changes within a single execution frame. - Frames form a hierarchy: Block → Transaction → Call frames. - Each frame holds a reference to its parent for upward traversal. + Frames form a hierarchy (Block -> Transaction -> Call) linked by parent + references. The block_access_index is stored at the root frame. Pre-state + captures (pre_balances, etc.) are only populated at the transaction level. """ parent: Optional["StateChanges"] = None - _block_access_index: BlockAccessIndex = BlockAccessIndex(0) + block_access_index: BlockAccessIndex = BlockAccessIndex(0) touched_addresses: Set[Address] = field(default_factory=set) storage_reads: Set[Tuple[Address, Bytes32]] = field(default_factory=set) @@ -61,7 +53,7 @@ class StateChanges: default_factory=dict ) - # Pre-state captures for net-zero filtering + # Pre-state captures (transaction-scoped, only populated at tx frame) pre_balances: Dict[Address, U256] = field(default_factory=dict) pre_nonces: Dict[Address, U64] = field(default_factory=dict) pre_storage: Dict[Tuple[Address, Bytes32], U256] = field( @@ -72,17 +64,17 @@ class StateChanges: def get_block_frame(state_changes: StateChanges) -> StateChanges: """ - Walk to block-level frame. + Walk to the root (block-level) frame. Parameters ---------- state_changes : - Any state changes frame. + Any frame in the hierarchy. Returns ------- block_frame : StateChanges - The block-level frame. + The root block-level frame. """ block_frame = state_changes @@ -91,125 +83,128 @@ def get_block_frame(state_changes: StateChanges) -> StateChanges: return block_frame -def get_block_access_index(root_frame: StateChanges) -> BlockAccessIndex: +def increment_block_access_index(root_frame: StateChanges) -> None: """ - Get current block access index from root frame. + Increment the block access index in the root frame. Parameters ---------- root_frame : - The root (block-level) state changes frame. - - Returns - ------- - index : BlockAccessIndex - The current block access index. + The root block-level frame. """ - return root_frame._block_access_index + root_frame.block_access_index = BlockAccessIndex( + root_frame.block_access_index + Uint(1) + ) -def increment_block_access_index(root_frame: StateChanges) -> None: +def get_transaction_frame(state_changes: StateChanges) -> StateChanges: """ - Increment block access index in root frame. + Walk to the transaction-level frame (child of block frame). Parameters ---------- - root_frame : - The root (block-level) state changes frame to increment. + state_changes : + Any frame in the hierarchy. + + Returns + ------- + tx_frame : StateChanges + The transaction-level frame. """ - root_frame._block_access_index = BlockAccessIndex( - root_frame._block_access_index + Uint(1) - ) + tx_frame = state_changes + while tx_frame.parent is not None and tx_frame.parent.parent is not None: + tx_frame = tx_frame.parent + return tx_frame def capture_pre_balance( - state_changes: StateChanges, address: Address, balance: U256 + tx_frame: StateChanges, address: Address, balance: U256 ) -> None: """ - Capture pre-balance (first-write-wins for net-zero filtering). + Capture pre-balance if not already captured (first-write-wins). Parameters ---------- - state_changes : - The state changes frame. + tx_frame : + The transaction-level frame. address : - The address whose balance is being captured. + The address whose balance to capture. balance : - The balance value before modification. + The current balance value. """ - if address not in state_changes.pre_balances: - state_changes.pre_balances[address] = balance + if address not in tx_frame.pre_balances: + tx_frame.pre_balances[address] = balance def capture_pre_nonce( - state_changes: StateChanges, address: Address, nonce: U64 + tx_frame: StateChanges, address: Address, nonce: U64 ) -> None: """ - Capture pre-nonce (first-write-wins). + Capture pre-nonce if not already captured (first-write-wins). Parameters ---------- - state_changes : - The state changes frame. + tx_frame : + The transaction-level frame. address : - The address whose nonce is being captured. + The address whose nonce to capture. nonce : - The nonce value before modification. + The current nonce value. """ - if address not in state_changes.pre_nonces: - state_changes.pre_nonces[address] = nonce + if address not in tx_frame.pre_nonces: + tx_frame.pre_nonces[address] = nonce def capture_pre_storage( - state_changes: StateChanges, address: Address, key: Bytes32, value: U256 + tx_frame: StateChanges, address: Address, key: Bytes32, value: U256 ) -> None: """ - Capture pre-storage (first-write-wins for noop filtering). + Capture pre-storage value if not already captured (first-write-wins). Parameters ---------- - state_changes : - The state changes frame. + tx_frame : + The transaction-level frame. address : - The address whose storage is being captured. + The address whose storage to capture. key : The storage key. value : - The storage value before modification. + The current storage value. """ slot = (address, key) - if slot not in state_changes.pre_storage: - state_changes.pre_storage[slot] = value + if slot not in tx_frame.pre_storage: + tx_frame.pre_storage[slot] = value def capture_pre_code( - state_changes: StateChanges, address: Address, code: Bytes + tx_frame: StateChanges, address: Address, code: Bytes ) -> None: """ - Capture pre-code (first-write-wins). + Capture pre-code if not already captured (first-write-wins). Parameters ---------- - state_changes : - The state changes frame. + tx_frame : + The transaction-level frame. address : - The address whose code is being captured. + The address whose code to capture. code : - The code value before modification. + The current code value. """ - if address not in state_changes.pre_code: - state_changes.pre_code[address] = code + if address not in tx_frame.pre_code: + tx_frame.pre_code[address] = code def track_address(state_changes: StateChanges, address: Address) -> None: """ - Track that an address was accessed. + Record that an address was accessed. Parameters ---------- @@ -226,7 +221,7 @@ def track_storage_read( state_changes: StateChanges, address: Address, key: Bytes32 ) -> None: """ - Track a storage read operation. + Record a storage read operation. Parameters ---------- @@ -248,7 +243,7 @@ def track_storage_write( value: U256, ) -> None: """ - Track a storage write operation with block access index. + Record a storage write keyed by (address, key, block_access_index). Parameters ---------- @@ -262,10 +257,8 @@ def track_storage_write( The new storage value. """ - block_frame = get_block_frame(state_changes) - state_changes.storage_writes[ - (address, key, get_block_access_index(block_frame)) - ] = value + idx = state_changes.block_access_index + state_changes.storage_writes[(address, key, idx)] = value def track_balance_change( @@ -274,7 +267,7 @@ def track_balance_change( new_balance: U256, ) -> None: """ - Track balance change keyed by (address, index). + Record a balance change keyed by (address, block_access_index). Parameters ---------- @@ -286,10 +279,8 @@ def track_balance_change( The new balance value. """ - block_frame = get_block_frame(state_changes) - state_changes.balance_changes[ - (address, get_block_access_index(block_frame)) - ] = new_balance + idx = state_changes.block_access_index + state_changes.balance_changes[(address, idx)] = new_balance def track_nonce_change( @@ -298,7 +289,7 @@ def track_nonce_change( new_nonce: U64, ) -> None: """ - Track a nonce change. + Record a nonce change as (address, block_access_index, new_nonce). Parameters ---------- @@ -310,10 +301,8 @@ def track_nonce_change( The new nonce value. """ - block_frame = get_block_frame(state_changes) - state_changes.nonce_changes.add( - (address, get_block_access_index(block_frame), new_nonce) - ) + idx = state_changes.block_access_index + state_changes.nonce_changes.add((address, idx, new_nonce)) def track_code_change( @@ -322,7 +311,7 @@ def track_code_change( new_code: Bytes, ) -> None: """ - Track a code change. + Record a code change keyed by (address, block_access_index). Parameters ---------- @@ -334,19 +323,55 @@ def track_code_change( The new code value. """ - block_frame = get_block_frame(state_changes) - state_changes.code_changes[ - (address, get_block_access_index(block_frame)) - ] = new_code + idx = state_changes.block_access_index + state_changes.code_changes[(address, idx)] = new_code + + +def track_selfdestruct( + state_changes: StateChanges, + address: Address, +) -> None: + """ + Handle selfdestruct of account created in same transaction. + + Per EIP-7928/EIP-6780: removes nonce/code changes, converts storage + writes to reads. Balance changes handled by net-zero filtering. + + Parameters + ---------- + state_changes : + The state changes tracker. + address : + The address that self-destructed. + + """ + idx = state_changes.block_access_index + + # Remove nonce changes from current transaction + state_changes.nonce_changes = { + (addr, i, nonce) + for addr, i, nonce in state_changes.nonce_changes + if not (addr == address and i == idx) + } + + # Remove code changes from current transaction + if (address, idx) in state_changes.code_changes: + del state_changes.code_changes[(address, idx)] + + # Convert storage writes from current transaction to reads + for addr, key, i in list(state_changes.storage_writes.keys()): + if addr == address and i == idx: + del state_changes.storage_writes[(addr, key, i)] + state_changes.storage_reads.add((addr, key)) def merge_on_success(child_frame: StateChanges) -> None: """ - Merge child frame's changes into parent on successful completion. + Merge child frame into parent on success. - Merges all tracked changes (reads and writes) from the child frame - into the parent frame. Filters out net-zero changes based on - captured pre-state values by comparing initial vs final values. + Child values overwrite parent values (most recent wins). No net-zero + filtering here - that happens once at transaction commit via + normalize_transaction(). Parameters ---------- @@ -356,50 +381,20 @@ def merge_on_success(child_frame: StateChanges) -> None: """ assert child_frame.parent is not None parent_frame = child_frame.parent + # Merge address accesses parent_frame.touched_addresses.update(child_frame.touched_addresses) - # Merge pre-state captures for transaction-level normalization - # Only if parent doesn't have value (first capture wins) - for addr, balance in child_frame.pre_balances.items(): - if addr not in parent_frame.pre_balances: - parent_frame.pre_balances[addr] = balance - for addr, nonce in child_frame.pre_nonces.items(): - if addr not in parent_frame.pre_nonces: - parent_frame.pre_nonces[addr] = nonce - for slot, value in child_frame.pre_storage.items(): - if slot not in parent_frame.pre_storage: - parent_frame.pre_storage[slot] = value - for addr, code in child_frame.pre_code.items(): - if addr not in parent_frame.pre_code: - capture_pre_code(parent_frame, addr, code) - - # Merge storage operations, filtering noop writes + # Merge storage: reads union, writes overwrite (child supersedes parent) parent_frame.storage_reads.update(child_frame.storage_reads) - for (addr, key, idx), value in child_frame.storage_writes.items(): - # Only merge if value actually changed from pre-state - if (addr, key) in child_frame.pre_storage: - if child_frame.pre_storage[(addr, key)] != value: - parent_frame.storage_writes[(addr, key, idx)] = value - # If equal, it's a noop write - convert to read only - else: - parent_frame.storage_reads.add((addr, key)) - else: - # No pre-state captured, merge as-is - parent_frame.storage_writes[(addr, key, idx)] = value - - # Merge balance changes - filter net-zero changes - # balance_changes keyed by (address, index) - for (addr, idx), final_balance in child_frame.balance_changes.items(): - if addr in child_frame.pre_balances: - if child_frame.pre_balances[addr] != final_balance: - parent_frame.balance_changes[(addr, idx)] = final_balance - # else: Net-zero change - skip entirely - else: - # No pre-balance captured, merge as-is - parent_frame.balance_changes[(addr, idx)] = final_balance - - # Merge nonce changes - keep only highest nonce per address + for storage_key, storage_value in child_frame.storage_writes.items(): + parent_frame.storage_writes[storage_key] = storage_value + + # Merge balance changes: child overwrites parent for same key + for balance_key, balance_value in child_frame.balance_changes.items(): + parent_frame.balance_changes[balance_key] = balance_value + + # Merge nonce changes: keep highest nonce per address address_final_nonces: Dict[Address, Tuple[BlockAccessIndex, U64]] = {} for addr, idx, nonce in child_frame.nonce_changes: if ( @@ -407,31 +402,46 @@ def merge_on_success(child_frame: StateChanges) -> None: or nonce > address_final_nonces[addr][1] ): address_final_nonces[addr] = (idx, nonce) - - # Merge final nonces (no net-zero filtering - nonces never decrease) for addr, (idx, final_nonce) in address_final_nonces.items(): parent_frame.nonce_changes.add((addr, idx, final_nonce)) - # Merge code changes - filter net-zero changes - # code_changes keyed by (address, index) - for (addr, idx), final_code in child_frame.code_changes.items(): - pre_code = child_frame.pre_code.get(addr, b"") - if pre_code != final_code: - parent_frame.code_changes[(addr, idx)] = final_code - # else: Net-zero change - skip entirely + # Merge code changes: child overwrites parent for same key + for code_key, code_value in child_frame.code_changes.items(): + parent_frame.code_changes[code_key] = code_value -def commit_transaction_frame(tx_frame: StateChanges) -> None: +def merge_on_failure(child_frame: StateChanges) -> None: """ - Commit a transaction frame's changes to the block frame. + Merge child frame into parent on failure/revert. + + Only reads merge; writes are discarded (converted to reads). + + Parameters + ---------- + child_frame : + The failed child frame. + + """ + assert child_frame.parent is not None + parent_frame = child_frame.parent + # Only merge reads and address accesses on failure + parent_frame.touched_addresses.update(child_frame.touched_addresses) + parent_frame.storage_reads.update(child_frame.storage_reads) + + # Convert writes to reads (failed writes still accessed the slots) + for address, key, _idx in child_frame.storage_writes.keys(): + parent_frame.storage_reads.add((address, key)) + + # Note: balance_changes, nonce_changes, and code_changes are NOT + # merged on failure - they are discarded + - Merges ALL changes from the transaction frame into the block frame - without net-zero filtering. Each transaction's changes are recorded - at their respective transaction index, even if a later transaction - reverts a change back to its original value. +def commit_transaction_frame(tx_frame: StateChanges) -> None: + """ + Commit transaction frame to block frame. - This is different from merge_on_success() which filters net-zero - changes within a single transaction's execution. + Unlike merge_on_success(), this merges ALL changes without net-zero + filtering (each tx's changes recorded at their respective index). Parameters ---------- @@ -458,46 +468,17 @@ def commit_transaction_frame(tx_frame: StateChanges) -> None: for addr, idx, nonce in tx_frame.nonce_changes: block_frame.nonce_changes.add((addr, idx, nonce)) - # Merge code changes - filter net-zero changes within the transaction - # Compare final code against transaction's pre-code + # Merge code changes (net-zero filtering done in normalize_transaction) for (addr, idx), final_code in tx_frame.code_changes.items(): - pre_code = tx_frame.pre_code.get(addr, b"") - if pre_code != final_code: - block_frame.code_changes[(addr, idx)] = final_code - # else: Net-zero change within this transaction - skip - - -def merge_on_failure(child_frame: StateChanges) -> None: - """ - Merge child frame's changes into parent on failed completion. - - Merges only read operations from the child frame into the parent. - Write operations are discarded since the frame reverted. - This is called when a call frame fails/reverts. - - Parameters - ---------- - child_frame : - The failed child frame. - - """ - assert child_frame.parent is not None - parent_frame = child_frame.parent - # Only merge reads and address accesses on failure - parent_frame.touched_addresses.update(child_frame.touched_addresses) - parent_frame.storage_reads.update(child_frame.storage_reads) - - # Convert writes to reads (failed writes still accessed the slots) - for address, key, _idx in child_frame.storage_writes.keys(): - parent_frame.storage_reads.add((address, key)) - - # Note: balance_changes, nonce_changes, and code_changes are NOT - # merged on failure - they are discarded + block_frame.code_changes[(addr, idx)] = final_code def create_child_frame(parent: StateChanges) -> StateChanges: """ - Create a child frame for nested execution. + Create a child frame linked to the given parent. + + Inherits block_access_index from parent so track functions can + access it directly without walking up the frame hierarchy. Parameters ---------- @@ -507,99 +488,69 @@ def create_child_frame(parent: StateChanges) -> StateChanges: Returns ------- child : StateChanges - A new child frame with parent reference set. + A new child frame with parent reference and inherited + block_access_index. """ - return StateChanges(parent=parent) - - -def handle_in_transaction_selfdestruct( - state_changes: StateChanges, - address: Address, - current_block_access_index: BlockAccessIndex, -) -> None: - """ - Handle account self-destructed in same transaction as creation. - - Per EIP-7928 and EIP-6780, accounts destroyed within their creation - transaction must have: - - Nonce changes from current transaction removed - - Code changes from current transaction removed - - Storage writes from current transaction converted to reads - - Balance changes handled by net-zero filtering - - Parameters - ---------- - state_changes : StateChanges - The state changes tracker (typically the block-level frame). - address : Address - The address that self-destructed. - current_block_access_index : BlockAccessIndex - The current block access index (transaction index). - - """ - # Remove nonce changes from current transaction - state_changes.nonce_changes = { - (addr, idx, nonce) - for addr, idx, nonce in state_changes.nonce_changes - if not (addr == address and idx == current_block_access_index) - } - - # Remove code changes from current transaction - if (address, current_block_access_index) in state_changes.code_changes: - del state_changes.code_changes[(address, current_block_access_index)] - - # Convert storage writes from current transaction to reads - for addr, key, idx in list(state_changes.storage_writes.keys()): - if addr == address and idx == current_block_access_index: - del state_changes.storage_writes[(addr, key, idx)] - state_changes.storage_reads.add((addr, key)) + return StateChanges( + parent=parent, + block_access_index=parent.block_access_index, + ) -def normalize_balance_changes_for_transaction( - block_frame: StateChanges, - current_block_access_index: BlockAccessIndex, +def filter_net_zero_frame_changes( + tx_frame: StateChanges, state: "State", ) -> None: """ - Normalize balance changes for the current transaction. + Filter net-zero changes from transaction frame before commit. - Removes balance changes where post-transaction balance equals - pre-transaction balance. This handles net-zero transfers across - the entire transaction. - - This function should be called after merging transaction frames - into the block frame to filter out addresses where balance didn't - actually change from transaction start to transaction end. + Compares final values against pre-tx state for storage, balance, and code. + Net-zero storage writes are converted to reads. Net-zero balance/code + changes are removed entirely. Nonces are not filtered (only increment). Parameters ---------- - block_frame : StateChanges - The block-level state changes frame. - current_block_access_index : BlockAccessIndex - The current transaction's block access index. - state : State - The current state to read final balances from. + tx_frame : + The transaction-level state changes frame. + state : + The current state to read final values from. """ # Import locally to avoid circular import from .state import get_account - # Collect addresses that have balance changes in this transaction + idx = tx_frame.block_access_index + + # Filter storage: compare against pre_storage, convert net-zero to reads + for addr, key, i in list(tx_frame.storage_writes.keys()): + if i != idx: + continue + final_value = tx_frame.storage_writes[(addr, key, i)] + if (addr, key) in tx_frame.pre_storage: + if tx_frame.pre_storage[(addr, key)] == final_value: + # Net-zero write - convert to read + del tx_frame.storage_writes[(addr, key, i)] + tx_frame.storage_reads.add((addr, key)) + + # Filter balance: compare pre vs post, remove if equal addresses_to_check = [ - addr - for (addr, idx) in block_frame.balance_changes.keys() - if idx == current_block_access_index + addr for (addr, i) in tx_frame.balance_changes.keys() if i == idx ] - - # For each address, compare pre vs post balance for addr in addresses_to_check: - if addr in block_frame.pre_balances: - pre_balance = block_frame.pre_balances[addr] + if addr in tx_frame.pre_balances: + pre_balance = tx_frame.pre_balances[addr] post_balance = get_account(state, addr).balance - if pre_balance == post_balance: - # Remove balance change for this address - net-zero transfer - del block_frame.balance_changes[ - (addr, current_block_access_index) - ] + del tx_frame.balance_changes[(addr, idx)] + + # Filter code: compare pre vs post, remove if equal + for addr, i in list(tx_frame.code_changes.keys()): + if i != idx: + continue + final_code = tx_frame.code_changes[(addr, i)] + pre_code = tx_frame.pre_code.get(addr, b"") + if pre_code == final_code: + del tx_frame.code_changes[(addr, i)] + + # Nonces: no filtering needed (nonces only increment, never net-zero) diff --git a/src/ethereum/forks/amsterdam/utils/message.py b/src/ethereum/forks/amsterdam/utils/message.py index def5b36e20..130532fef6 100644 --- a/src/ethereum/forks/amsterdam/utils/message.py +++ b/src/ethereum/forks/amsterdam/utils/message.py @@ -17,7 +17,7 @@ from ..fork_types import Address from ..state import get_account -from ..state_tracker import StateChanges +from ..state_tracker import create_child_frame from ..transactions import Transaction from ..vm import BlockEnvironment, Message, TransactionEnvironment from ..vm.precompiled_contracts.mapping import PRE_COMPILED_CONTRACTS @@ -28,7 +28,6 @@ def prepare_message( block_env: BlockEnvironment, tx_env: TransactionEnvironment, tx: Transaction, - transaction_state_changes: StateChanges, ) -> Message: """ Execute a transaction against the provided environment. @@ -41,8 +40,6 @@ def prepare_message( Environment for the transaction. tx : Transaction to be executed. - transaction_state_changes : - State changes specific to this transaction. Returns ------- @@ -73,6 +70,9 @@ def prepare_message( accessed_addresses.add(current_target) + # Create call frame as child of transaction frame + call_frame = create_child_frame(tx_env.state_changes) + return Message( block_env=block_env, tx_env=tx_env, @@ -91,5 +91,6 @@ def prepare_message( accessed_storage_keys=set(tx_env.access_list_storage_keys), disable_precompiles=False, parent_evm=None, - transaction_state_changes=transaction_state_changes, + is_create=isinstance(tx.to, Bytes0), + state_changes=call_frame, ) diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index d414aa50f9..6726880fcb 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -49,9 +49,7 @@ class BlockEnvironment: prev_randao: Bytes32 excess_blob_gas: U64 parent_beacon_block_root: Hash32 - block_state_changes: StateChanges = field( - default_factory=lambda: StateChanges() - ) + state_changes: StateChanges @dataclass @@ -117,6 +115,7 @@ class TransactionEnvironment: authorizations: Tuple[Authorization, ...] index_in_block: Optional[Uint] tx_hash: Optional[Hash32] + state_changes: "StateChanges" = field(default_factory=StateChanges) @dataclass @@ -142,7 +141,8 @@ class Message: accessed_storage_keys: Set[Tuple[Address, Bytes32]] disable_precompiles: bool parent_evm: Optional["Evm"] - transaction_state_changes: StateChanges + is_create: bool + state_changes: "StateChanges" = field(default_factory=StateChanges) @dataclass diff --git a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py index ec95fd1a47..1f1aac9d97 100644 --- a/src/ethereum/forks/amsterdam/vm/eoa_delegation.py +++ b/src/ethereum/forks/amsterdam/vm/eoa_delegation.py @@ -12,7 +12,6 @@ from ethereum.crypto.hash import keccak256 from ethereum.exceptions import InvalidBlock, InvalidSignatureError -# track_address_access removed - now using state_changes.track_address() from ..fork_types import Address, Authorization from ..state import ( account_exists, @@ -20,7 +19,12 @@ increment_nonce, set_authority_code, ) -from ..state_tracker import capture_pre_code, track_address +from ..state_tracker import ( + capture_pre_code, + track_address, + track_code_change, + track_nonce_change, +) from ..utils.hexadecimal import hex_to_address from ..vm.gas import GAS_COLD_ACCOUNT_ACCESS, GAS_WARM_ACCESS from . import Evm, Message @@ -190,11 +194,9 @@ def read_delegation_target(evm: Evm, delegated_address: Address) -> Bytes: """ state = evm.message.block_env.state - # Add to accessed addresses for warm/cold gas accounting if delegated_address not in evm.accessed_addresses: evm.accessed_addresses.add(delegated_address) - # Track the address for BAL track_address(evm.state_changes, delegated_address) return get_account(state, delegated_address).code @@ -236,7 +238,7 @@ def set_delegation(message: Message) -> U256: authority_account = get_account(state, authority) authority_code = authority_account.code - track_address(message.block_env.block_state_changes, authority) + track_address(message.tx_env.state_changes, authority) if authority_code and not is_valid_delegation(authority_code): continue @@ -253,22 +255,19 @@ def set_delegation(message: Message) -> U256: else: code_to_set = EOA_DELEGATION_MARKER + auth.address - state_changes = ( - message.transaction_state_changes - or message.block_env.block_state_changes - ) + tx_frame = message.tx_env.state_changes + # EIP-7928: Capture pre-code before any changes + capture_pre_code(tx_frame, authority, authority_code) - # Capture pre-code before any changes (first-write-wins) - capture_pre_code(state_changes, authority, authority_code) + set_authority_code(state, authority, code_to_set) - # Set delegation code - # Uses authority_code (current) for tracking to handle multiple auths - # Net-zero filtering happens in commit_transaction_frame - set_authority_code( - state, authority, code_to_set, state_changes, authority_code - ) + if authority_code != code_to_set: + # Track code change if different from current + track_code_change(tx_frame, authority, code_to_set) - increment_nonce(state, authority, state_changes) + increment_nonce(state, authority) + nonce_after = get_account(state, authority).nonce + track_nonce_change(tx_frame, authority, U64(nonce_after)) if message.code_address is None: raise InvalidBlock("Invalid type 4 transaction: no target") diff --git a/src/ethereum/forks/amsterdam/vm/instructions/storage.py b/src/ethereum/forks/amsterdam/vm/instructions/storage.py index 8edff23534..de7ef935f5 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/storage.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/storage.py @@ -26,7 +26,7 @@ track_storage_write, ) from .. import Evm -from ..exceptions import OutOfGasError, WriteInStaticContext +from ..exceptions import WriteInStaticContext from ..gas import ( GAS_CALL_STIPEND, GAS_COLD_SLOAD, @@ -55,25 +55,26 @@ def sload(evm: Evm) -> None: key = pop(evm.stack).to_be_bytes32() # GAS - gas_cost = ( - GAS_WARM_ACCESS - if (evm.message.current_target, key) in evm.accessed_storage_keys - else GAS_COLD_SLOAD - ) - check_gas(evm, gas_cost) - if (evm.message.current_target, key) not in evm.accessed_storage_keys: - evm.accessed_storage_keys.add((evm.message.current_target, key)) - track_storage_read( - evm.state_changes, + is_cold_access = ( evm.message.current_target, key, - ) + ) not in evm.accessed_storage_keys + gas_cost = GAS_COLD_SLOAD if is_cold_access else GAS_WARM_ACCESS + charge_gas(evm, gas_cost) # OPERATION state = evm.message.block_env.state value = get_storage(state, evm.message.current_target, key) + if is_cold_access: + evm.accessed_storage_keys.add((evm.message.current_target, key)) + track_storage_read( + evm.state_changes, + evm.message.current_target, + key, + ) + push(evm.stack, value) # PROGRAM COUNTER @@ -93,19 +94,14 @@ def sstore(evm: Evm) -> None: # STACK key = pop(evm.stack).to_be_bytes32() new_value = pop(evm.stack) - if evm.gas_left <= GAS_CALL_STIPEND: - raise OutOfGasError - # Check static context before accessing storage + # check we have at least the stipend gas + check_gas(evm, GAS_CALL_STIPEND + Uint(1)) + + # check static context before accessing storage if evm.message.is_static: raise WriteInStaticContext - state = evm.message.block_env.state - original_value = get_storage_original( - state, evm.message.current_target, key - ) - current_value = get_storage(state, evm.message.current_target, key) - # GAS gas_cost = Uint(0) is_cold_access = ( @@ -116,28 +112,34 @@ def sstore(evm: Evm) -> None: if is_cold_access: gas_cost += GAS_COLD_SLOAD - if original_value == current_value and current_value != new_value: - if original_value == 0: - gas_cost += GAS_STORAGE_SET - else: - gas_cost += GAS_STORAGE_UPDATE - GAS_COLD_SLOAD - else: - gas_cost += GAS_WARM_ACCESS + state = evm.message.block_env.state + original_value = get_storage_original( + state, evm.message.current_target, key + ) + current_value = get_storage(state, evm.message.current_target, key) + + if is_cold_access: + evm.accessed_storage_keys.add((evm.message.current_target, key)) - # Track storage access BEFORE checking gas (EIP-7928) - # Even if we run out of gas, the access attempt should be tracked capture_pre_storage( - evm.state_changes, evm.message.current_target, key, current_value + evm.message.tx_env.state_changes, + evm.message.current_target, + key, + current_value, ) track_storage_read( evm.state_changes, evm.message.current_target, key, ) - check_gas(evm, gas_cost) - if is_cold_access: - evm.accessed_storage_keys.add((evm.message.current_target, key)) + if original_value == current_value and current_value != new_value: + if original_value == 0: + gas_cost += GAS_STORAGE_SET + else: + gas_cost += GAS_STORAGE_UPDATE - GAS_COLD_SLOAD + else: + gas_cost += GAS_WARM_ACCESS charge_gas(evm, gas_cost) @@ -165,7 +167,10 @@ def sstore(evm: Evm) -> None: # OPERATION set_storage(state, evm.message.current_target, key, new_value) track_storage_write( - evm.state_changes, evm.message.current_target, key, new_value + evm.state_changes, + evm.message.current_target, + key, + new_value, ) # PROGRAM COUNTER diff --git a/src/ethereum/forks/amsterdam/vm/instructions/system.py b/src/ethereum/forks/amsterdam/vm/instructions/system.py index 288594bfe7..e5e2ec306f 100644 --- a/src/ethereum/forks/amsterdam/vm/instructions/system.py +++ b/src/ethereum/forks/amsterdam/vm/instructions/system.py @@ -12,7 +12,7 @@ """ from ethereum_types.bytes import Bytes, Bytes0 -from ethereum_types.numeric import U256, Uint +from ethereum_types.numeric import U64, U256, Uint from ethereum.utils.numeric import ceil32 @@ -27,7 +27,13 @@ move_ether, set_account_balance, ) -from ...state_tracker import capture_pre_balance, track_address +from ...state_tracker import ( + capture_pre_balance, + create_child_frame, + track_address, + track_balance_change, + track_nonce_change, +) from ...utils.address import ( compute_contract_address, compute_create2_contract_address, @@ -120,11 +126,27 @@ def generic_create( if account_has_code_or_nonce( state, contract_address ) or account_has_storage(state, contract_address): - increment_nonce(state, evm.message.current_target, evm.state_changes) + increment_nonce(state, evm.message.current_target) + nonce_after = get_account(state, evm.message.current_target).nonce + track_nonce_change( + evm.state_changes, + evm.message.current_target, + U64(nonce_after), + ) push(evm.stack, U256(0)) return - increment_nonce(state, evm.message.current_target, evm.state_changes) + # Track nonce increment for CREATE + increment_nonce(state, evm.message.current_target) + nonce_after = get_account(state, evm.message.current_target).nonce + track_nonce_change( + evm.state_changes, + evm.message.current_target, + U64(nonce_after), + ) + + # Create call frame as child of parent EVM's frame + child_state_changes = create_child_frame(evm.state_changes) child_message = Message( block_env=evm.message.block_env, @@ -144,7 +166,8 @@ def generic_create( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=False, parent_evm=evm, - transaction_state_changes=evm.message.transaction_state_changes, + is_create=True, + state_changes=child_state_changes, ) child_evm = process_create_message(child_message) @@ -321,8 +344,9 @@ def generic_call( evm.memory, memory_input_start_position, memory_input_size ) - # EIP-7928: Child message inherits transaction_state_changes from parent - # The actual child frame will be created automatically in process_message + # Create call frame as child of parent EVM's frame + child_state_changes = create_child_frame(evm.state_changes) + child_message = Message( block_env=evm.message.block_env, tx_env=evm.message.tx_env, @@ -341,7 +365,8 @@ def generic_call( accessed_storage_keys=evm.accessed_storage_keys.copy(), disable_precompiles=disable_precompiles, parent_evm=evm, - transaction_state_changes=evm.message.transaction_state_changes, + is_create=False, + state_changes=child_state_changes, ) child_evm = process_message(child_message) @@ -570,11 +595,13 @@ def callcode(evm: Evm) -> None: ).balance # EIP-7928: For CALLCODE with value transfer, capture pre-balance - # in parent frame. CALLCODE transfers value from/to current_target + # in transaction frame. CALLCODE transfers value from/to current_target # (same address), affecting current storage context, not child frame if value != 0 and sender_balance >= value: capture_pre_balance( - evm.state_changes, evm.message.current_target, sender_balance + evm.message.tx_env.state_changes, + evm.message.current_target, + sender_balance, ) if sender_balance < value: @@ -643,25 +670,41 @@ def selfdestruct(evm: Evm) -> None: charge_gas(evm, gas_cost) + state = evm.message.block_env.state originator = evm.message.current_target - originator_balance = get_account( - evm.message.block_env.state, originator - ).balance + originator_balance = get_account(state, originator).balance + beneficiary_balance = get_account(state, beneficiary).balance + + # Get tracking context + tx_frame = evm.message.tx_env.state_changes + + # Capture pre-balances for net-zero filtering + track_address(evm.state_changes, originator) + capture_pre_balance(tx_frame, originator, originator_balance) + capture_pre_balance(tx_frame, beneficiary, beneficiary_balance) - move_ether( - evm.message.block_env.state, + # Transfer balance + move_ether(state, originator, beneficiary, originator_balance) + + # Track balance changes + originator_new_balance = get_account(state, originator).balance + beneficiary_new_balance = get_account(state, beneficiary).balance + track_balance_change( + evm.state_changes, originator, - beneficiary, - originator_balance, + originator_new_balance, + ) + track_balance_change( evm.state_changes, + beneficiary, + beneficiary_new_balance, ) # register account for deletion only if it was created # in the same transaction - if originator in evm.message.block_env.state.created_accounts: - set_account_balance( - evm.message.block_env.state, originator, U256(0), evm.state_changes - ) + if originator in state.created_accounts: + set_account_balance(state, originator, U256(0)) + track_balance_change(evm.state_changes, originator, U256(0)) evm.accounts_to_delete.add(originator) # HALT the execution diff --git a/src/ethereum/forks/amsterdam/vm/interpreter.py b/src/ethereum/forks/amsterdam/vm/interpreter.py index 893a0d8833..154c56de11 100644 --- a/src/ethereum/forks/amsterdam/vm/interpreter.py +++ b/src/ethereum/forks/amsterdam/vm/interpreter.py @@ -15,7 +15,7 @@ from typing import Optional, Set, Tuple from ethereum_types.bytes import Bytes, Bytes0 -from ethereum_types.numeric import U256, Uint, ulen +from ethereum_types.numeric import U64, U256, Uint, ulen from ethereum.exceptions import EthereumException from ethereum.trace import ( @@ -46,10 +46,13 @@ ) from ..state_tracker import ( StateChanges, - create_child_frame, + capture_pre_balance, merge_on_failure, merge_on_success, track_address, + track_balance_change, + track_code_change, + track_nonce_change, ) from ..vm import Message from ..vm.eoa_delegation import get_delegated_code_address, set_delegation @@ -73,61 +76,6 @@ MAX_INIT_CODE_SIZE = 2 * MAX_CODE_SIZE -def get_parent_frame(message: Message) -> StateChanges: - """ - Get the appropriate parent frame for a message's state changes. - - Frame selection logic: - - Nested calls: Parent EVM's frame - - Top-level calls: Transaction frame - - System transactions: Block frame - - Parameters - ---------- - message : - The message being processed. - - Returns - ------- - parent_frame : StateChanges - The parent frame to use for creating child frames. - - """ - if message.parent_evm is not None: - return message.parent_evm.state_changes - elif message.transaction_state_changes is not None: - return message.transaction_state_changes - else: - return message.block_env.block_state_changes - - -def get_message_state_frame(message: Message) -> StateChanges: - """ - Determine and create the appropriate state tracking frame for a message. - - Creates a call frame as a child of the appropriate parent frame. - - Parameters - ---------- - message : - The message being processed. - - Returns - ------- - state_frame : StateChanges - The state tracking frame to use for this message execution. - - """ - parent_frame = get_parent_frame(message) - if ( - message.parent_evm is not None - or message.transaction_state_changes is not None - ): - return create_child_frame(parent_frame) - else: - return parent_frame - - @dataclass class MessageCallOutput: """ @@ -173,9 +121,7 @@ def process_message_call(message: Message) -> MessageCallOutput: is_collision = account_has_code_or_nonce( block_env.state, message.current_target ) or account_has_storage(block_env.state, message.current_target) - track_address( - message.transaction_state_changes, message.current_target - ) + track_address(message.tx_env.state_changes, message.current_target) if is_collision: return MessageCallOutput( Uint(0), @@ -197,11 +143,7 @@ def process_message_call(message: Message) -> MessageCallOutput: message.accessed_addresses.add(delegated_address) message.code = get_account(block_env.state, delegated_address).code message.code_address = delegated_address - - # EIP-7928: Track delegation target when loaded as call target - track_address( - message.block_env.block_state_changes, delegated_address - ) + track_address(message.block_env.state_changes, delegated_address) evm = process_message(message) @@ -263,11 +205,15 @@ def process_create_message(message: Message) -> Evm: # added to SELFDESTRUCT by EIP-6780. mark_account_created(state, message.current_target) - parent_frame = get_parent_frame(message) - create_frame = create_child_frame(parent_frame) + increment_nonce(state, message.current_target) + nonce_after = get_account(state, message.current_target).nonce + track_nonce_change( + message.state_changes, + message.current_target, + U64(nonce_after), + ) - increment_nonce(state, message.current_target, create_frame) - evm = process_message(message, parent_state_frame=create_frame) + evm = process_message(message) if not evm.error: contract_code = evm.output contract_code_gas = Uint(len(contract_code)) * GAS_CODE_DEPOSIT @@ -280,26 +226,28 @@ def process_create_message(message: Message) -> Evm: raise OutOfGasError except ExceptionalHalt as error: rollback_transaction(state, transient_storage) - merge_on_failure(create_frame) + merge_on_failure(message.state_changes) evm.gas_left = Uint(0) evm.output = b"" evm.error = error else: - set_code( - state, message.current_target, contract_code, create_frame - ) + # Note: No need to capture pre code since it's always b"" here + set_code(state, message.current_target, contract_code) + if contract_code != b"": + track_code_change( + message.state_changes, + message.current_target, + contract_code, + ) commit_transaction(state, transient_storage) - merge_on_success(create_frame) + merge_on_success(message.state_changes) else: rollback_transaction(state, transient_storage) - merge_on_failure(create_frame) + merge_on_failure(message.state_changes) return evm -def process_message( - message: Message, - parent_state_frame: Optional[StateChanges] = None, -) -> Evm: +def process_message(message: Message) -> Evm: """ Move ether and execute the relevant code. @@ -307,12 +255,6 @@ def process_message( ---------- message : Transaction specific items. - parent_state_frame : - Optional parent frame for state tracking. When provided (e.g., for - CREATE's init code), state changes are tracked as a child of this - frame instead of the default parent determined by the message. - This ensures proper frame hierarchy for CREATE operations where - init code changes must be children of the CREATE frame. Returns ------- @@ -325,37 +267,54 @@ def process_message( if message.depth > STACK_DEPTH_LIMIT: raise StackDepthLimitError("Stack depth limit reached") + # take snapshot of state before processing the message begin_transaction(state, transient_storage) - if parent_state_frame is not None: - # Use provided parent for CREATE's init code execution. - # This ensures init code state changes are children of create_frame, - # so they are properly converted to reads if code deposit fails. - parent_changes = parent_state_frame - state_changes = create_child_frame(parent_state_frame) - else: - parent_changes = get_parent_frame(message) - state_changes = get_message_state_frame(message) - - track_address(state_changes, message.current_target) + track_address(message.state_changes, message.current_target) if message.should_transfer_value and message.value != 0: + # Track value transfer + sender_balance = get_account(state, message.caller).balance + recipient_balance = get_account(state, message.current_target).balance + + track_address(message.state_changes, message.caller) + capture_pre_balance( + message.tx_env.state_changes, message.caller, sender_balance + ) + capture_pre_balance( + message.tx_env.state_changes, + message.current_target, + recipient_balance, + ) + move_ether( - state, + state, message.caller, message.current_target, message.value + ) + + sender_new_balance = get_account(state, message.caller).balance + recipient_new_balance = get_account( + state, message.current_target + ).balance + + track_balance_change( + message.state_changes, message.caller, + U256(sender_new_balance), + ) + track_balance_change( + message.state_changes, message.current_target, - message.value, - state_changes, + U256(recipient_new_balance), ) - evm = execute_code(message, state_changes) + evm = execute_code(message, message.state_changes) if evm.error: rollback_transaction(state, transient_storage) - if state_changes != parent_changes: + if not message.is_create: merge_on_failure(evm.state_changes) else: commit_transaction(state, transient_storage) - if state_changes != parent_changes: + if not message.is_create: merge_on_success(evm.state_changes) return evm diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index 2d3f8b9e3c..c029091467 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -16,7 +16,9 @@ from ethereum import trace from ethereum.exceptions import EthereumException, InvalidBlock from ethereum.fork_criteria import ByBlockNumber, ByTimestamp, Unscheduled -from ethereum_spec_tools.forks import Hardfork, TemporaryHardfork +from ethereum_spec_tools.forks import TemporaryHardfork +from ethereum.forks.amsterdam.state_tracker import StateChanges +from ethereum_spec_tools.forks import Hardfork from ..loaders.fixture_loader import Load from ..utils import ( @@ -308,6 +310,9 @@ def block_environment(self) -> Any: ) kw_arguments["excess_blob_gas"] = self.env.excess_blob_gas + if self.fork.is_after_fork("amsterdam"): + kw_arguments["state_changes"] = StateChanges() + return block_environment(**kw_arguments) def backup_state(self) -> None: @@ -417,7 +422,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: increment_block_access_index, ) - increment_block_access_index(block_env.block_state_changes) + increment_block_access_index(block_env.state_changes) if not self.fork.proof_of_stake: if self.options.state_reward is None: @@ -436,9 +441,9 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: self.fork.process_general_purpose_requests(block_env, block_output) if self.fork.is_after_fork("amsterdam"): - # Build block access list from block_env.block_state_changes + # Build block access list from block_env.state_changes block_output.block_access_list = self.fork.build_block_access_list( - block_env.block_state_changes + block_env.state_changes ) def run_blockchain_test(self) -> None: From b6c9410b635f69cbd8b69242d0b707cf4927a526 Mon Sep 17 00:00:00 2001 From: Guruprasad Kamath <48196632+gurukamath@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:40:24 +0100 Subject: [PATCH 44/51] fix(spec,tests): Change BAL to List[AccountChange] (#1844) * fix(spec): update BAL type * fix(tests): run amsterdam jsons * fix(test-specs): Add BAL to genesis beyond Amsterdam fork --------- Co-authored-by: fselmo --- .../src/execution_testing/specs/blockchain.py | 5 +++++ .../amsterdam/block_access_lists/builder.py | 8 ++++---- .../amsterdam/block_access_lists/rlp_types.py | 13 ++----------- .../amsterdam/block_access_lists/rlp_utils.py | 10 ++++------ src/ethereum/forks/amsterdam/vm/__init__.py | 4 +--- .../evm_tools/loaders/fixture_loader.py | 4 ++++ .../evm_tools/t8n/t8n_types.py | 18 ++++++------------ tests/json_infra/__init__.py | 6 +++++- .../helpers/load_blockchain_tests.py | 3 +++ 9 files changed, 34 insertions(+), 37 deletions(-) diff --git a/packages/testing/src/execution_testing/specs/blockchain.py b/packages/testing/src/execution_testing/specs/blockchain.py index e08738c1ce..207b4a953f 100644 --- a/packages/testing/src/execution_testing/specs/blockchain.py +++ b/packages/testing/src/execution_testing/specs/blockchain.py @@ -551,11 +551,16 @@ def make_genesis( state_root = pre_alloc.state_root() genesis = FixtureHeader.genesis(self.fork, env, state_root) + genesis_bal = None + if self.fork.header_bal_hash_required(block_number=0, timestamp=0): + genesis_bal = BlockAccessList() + return ( pre_alloc, FixtureBlockBase( header=genesis, withdrawals=None if env.withdrawals is None else [], + block_access_list=genesis_bal, ).with_rlp(txs=[]), ) diff --git a/src/ethereum/forks/amsterdam/block_access_lists/builder.py b/src/ethereum/forks/amsterdam/block_access_lists/builder.py index c1dbf98222..e860c84068 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/builder.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/builder.py @@ -405,7 +405,7 @@ def _build_from_builder( [`BlockAccessList`]: ref:ethereum.forks.amsterdam.block_access_lists.rlp_types.BlockAccessList # noqa: E501 """ - account_changes_list = [] + block_access_list: BlockAccessList = [] for address, changes in builder.accounts.items(): storage_changes = [] @@ -444,11 +444,11 @@ def _build_from_builder( code_changes=code_changes, ) - account_changes_list.append(account_change) + block_access_list.append(account_change) - account_changes_list.sort(key=lambda x: x.address) + block_access_list.sort(key=lambda x: x.address) - return BlockAccessList(account_changes=tuple(account_changes_list)) + return block_access_list def build_block_access_list( diff --git a/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py index e4d37d6a74..c4f49ff4aa 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_types.py @@ -8,7 +8,7 @@ """ from dataclasses import dataclass -from typing import Tuple +from typing import List, Tuple from ethereum_types.bytes import Bytes, Bytes20, Bytes32 from ethereum_types.frozen import slotted_freezable @@ -118,13 +118,4 @@ class AccountChanges: code_changes: Tuple[CodeChange, ...] -@slotted_freezable -@dataclass -class BlockAccessList: - """ - Block-Level Access List for EIP-7928. - Contains all addresses accessed during block execution. - RLP encoded as a list of AccountChanges. - """ - - account_changes: Tuple[AccountChanges, ...] +BlockAccessList = List[AccountChanges] diff --git a/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py index 738abce181..2cd5b827f3 100644 --- a/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py +++ b/src/ethereum/forks/amsterdam/block_access_lists/rlp_utils.py @@ -71,7 +71,7 @@ def rlp_encode_block_access_list(block_access_list: BlockAccessList) -> Bytes: """ # Encode as a list of AccountChanges directly (not wrapped) account_changes_list = [] - for account in block_access_list.account_changes: + for account in block_access_list: # Each account is encoded as: # [address, storage_changes, storage_reads, # balance_changes, nonce_changes, code_changes] @@ -146,7 +146,7 @@ def validate_block_access_list_against_execution( # 1. Validate structural constraints # Check that storage changes and reads don't overlap for the same slot - for account in block_access_list.account_changes: + for account in block_access_list: changed_slots = {sc.slot for sc in account.storage_changes} read_slots = set(account.storage_reads) @@ -155,9 +155,7 @@ def validate_block_access_list_against_execution( return False # 2. Validate ordering (addresses should be sorted lexicographically) - addresses = [ - account.address for account in block_access_list.account_changes - ] + addresses = [account.address for account in block_access_list] if addresses != sorted(addresses): return False @@ -165,7 +163,7 @@ def validate_block_access_list_against_execution( max_block_access_index = ( MAX_TXS + 1 ) # 0 for pre-exec, 1..MAX_TXS for txs, MAX_TXS+1 for post-exec - for account in block_access_list.account_changes: + for account in block_access_list: # Validate storage slots are sorted within each account storage_slots = [sc.slot for sc in account.storage_changes] if storage_slots != sorted(storage_slots): diff --git a/src/ethereum/forks/amsterdam/vm/__init__.py b/src/ethereum/forks/amsterdam/vm/__init__.py index 6726880fcb..6c47b50acf 100644 --- a/src/ethereum/forks/amsterdam/vm/__init__.py +++ b/src/ethereum/forks/amsterdam/vm/__init__.py @@ -94,9 +94,7 @@ class BlockOutput: ) blob_gas_used: U64 = U64(0) requests: List[Bytes] = field(default_factory=list) - block_access_list: BlockAccessList = field( - default_factory=lambda: BlockAccessList(account_changes=()) - ) + block_access_list: BlockAccessList = field(default_factory=list) @dataclass diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fixture_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fixture_loader.py index d01eda47c9..ab9e1b99d9 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fixture_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fixture_loader.py @@ -196,4 +196,8 @@ def json_to_header(self, raw: Any) -> Any: requests_hash = hex_to_bytes32(raw.get("requestsHash")) parameters.append(requests_hash) + if "blockAccessListHash" in raw: + bal_hash = hex_to_bytes32(raw.get("blockAccessListHash")) + parameters.append(bal_hash) + return self.fork.Header(*parameters) diff --git a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py index 1aac5e4b3f..cb13727f0d 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/t8n_types.py @@ -333,18 +333,17 @@ def update(self, t8n: "T8N", block_env: Any, block_output: Any) -> None: ) ) - def _block_access_list_to_json(self, bal: Any) -> Any: + @staticmethod + def _block_access_list_to_json(account_changes: Any) -> Any: """ Convert BlockAccessList to JSON format matching the Pydantic models. """ - account_changes = [] - - for account in bal.account_changes: + json_account_changes = [] + for account in account_changes: account_data: Dict[str, Any] = { "address": "0x" + account.address.hex() } - # Add storage changes if present if account.storage_changes: storage_changes = [] for slot_change in account.storage_changes: @@ -364,14 +363,12 @@ def _block_access_list_to_json(self, bal: Any) -> Any: storage_changes.append(slot_data) account_data["storageChanges"] = storage_changes - # Add storage reads if present if account.storage_reads: account_data["storageReads"] = [ int.from_bytes(slot, "big") for slot in account.storage_reads ] - # Add balance changes if present if account.balance_changes: account_data["balanceChanges"] = [ { @@ -381,7 +378,6 @@ def _block_access_list_to_json(self, bal: Any) -> Any: for change in account.balance_changes ] - # Add nonce changes if present if account.nonce_changes: account_data["nonceChanges"] = [ { @@ -391,7 +387,6 @@ def _block_access_list_to_json(self, bal: Any) -> Any: for change in account.nonce_changes ] - # Add code changes if present if account.code_changes: account_data["codeChanges"] = [ { @@ -401,10 +396,9 @@ def _block_access_list_to_json(self, bal: Any) -> Any: for change in account.code_changes ] - account_changes.append(account_data) + json_account_changes.append(account_data) - # return as list directly - return account_changes + return json_account_changes def json_encode_receipts(self) -> Any: """ diff --git a/tests/json_infra/__init__.py b/tests/json_infra/__init__.py index fd2db5553f..6fa7c5dbbe 100644 --- a/tests/json_infra/__init__.py +++ b/tests/json_infra/__init__.py @@ -27,9 +27,13 @@ class _FixtureSource(TypedDict): "fixture_path": "tests/json_infra/fixtures/ethereum_tests", }, "latest_fork_tests": { - "url": "https://github.com/ethereum/execution-spec-tests/releases/download/v5.0.0/fixtures_develop.tar.gz", + "url": "https://github.com/ethereum/execution-spec-tests/releases/download/v5.3.0/fixtures_develop.tar.gz", "fixture_path": "tests/json_infra/fixtures/latest_fork_tests", }, + "amsterdam_tests": { + "url": "https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v1.8.0/fixtures_bal.tar.gz", + "fixture_path": "tests/json_infra/fixtures/amsterdam_tests", + }, } diff --git a/tests/json_infra/helpers/load_blockchain_tests.py b/tests/json_infra/helpers/load_blockchain_tests.py index f390a1c1fd..6e86c48d56 100644 --- a/tests/json_infra/helpers/load_blockchain_tests.py +++ b/tests/json_infra/helpers/load_blockchain_tests.py @@ -138,6 +138,9 @@ def runtest(self) -> None: if hasattr(genesis_header, "requests_root"): parameters.append(()) + if hasattr(genesis_header, "block_access_list_hash"): + parameters.append([]) + genesis_block = load.fork.Block(*parameters) genesis_header_hash = hex_to_bytes( From 051adb0bb85d78412e226936bb76269f8de01859 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 8 Dec 2025 12:52:56 -0700 Subject: [PATCH 45/51] fix mkdocs --- tests/cancun/create/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/cancun/create/__init__.py diff --git a/tests/cancun/create/__init__.py b/tests/cancun/create/__init__.py new file mode 100644 index 0000000000..5297fcc089 --- /dev/null +++ b/tests/cancun/create/__init__.py @@ -0,0 +1 @@ +"""Create tests starting at Cancun.""" From 88829607e2bc4d133c87387144e6766b57bf374e Mon Sep 17 00:00:00 2001 From: Stefan <22667037+qu0b@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:14:15 +0100 Subject: [PATCH 46/51] Qu0b/add bal test cases (#1812) * rebase onto upstream * chore(fix) balance check in call before target access * merge test cases with usptream * merge test cases with usptream * chore(fix) format with ruff * chore(fix) revert call changes and add target to bal * merge test cases with usptream * improve wording * chore(formate) fix formatting and line length * refactor(test-tests): Use pre API where possible; explicit check for none in BAL * refactor(test-tests): Refactor opcode tests to bal opcodes test file --------- Co-authored-by: fselmo --- .../test_block_access_lists.py | 334 ++++++++++ .../test_block_access_lists_eip7702.py | 243 +++++++ .../test_block_access_lists_opcodes.py | 614 ++++++++++++++++++ .../test_cases.md | 17 +- 4 files changed, 1204 insertions(+), 4 deletions(-) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index 50fb9e0ad2..b17935e1e3 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -8,6 +8,7 @@ Account, Address, Alloc, + AuthorizationTuple, BalAccountExpectation, BalBalanceChange, BalCodeChange, @@ -23,6 +24,7 @@ Header, Op, Transaction, + add_kzg_version, compute_create_address, ) @@ -2145,3 +2147,335 @@ def test_bal_cross_block_ripemd160_state_leak( ripemd160_addr: Account(balance=1), }, ) + + +def test_bal_all_transaction_types( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with all 5 tx types in single block. + + Types: Legacy, EIP-2930, EIP-1559, Blob, EIP-7702. + Each tx writes to contract storage. Access list addresses are pre-warmed + but NOT in BAL. + + Expected BAL: + - All 5 senders: nonce_changes + - Contracts 0-3: storage_changes + - Alice (7702): nonce_changes, code_changes (delegation), storage_changes + - Oracle: empty (delegation target, accessed) + """ + from tests.prague.eip7702_set_code_tx.spec import Spec as Spec7702 + + # Create senders for each transaction type + sender_0 = pre.fund_eoa() # Type 0 - Legacy + sender_1 = pre.fund_eoa() # Type 1 - Access List + sender_2 = pre.fund_eoa() # Type 2 - EIP-1559 + sender_3 = pre.fund_eoa() # Type 3 - Blob + sender_4 = pre.fund_eoa() # Type 4 - EIP-7702 + + # Create contracts for each tx type (except 7702 which uses delegation) + contract_code = Op.SSTORE(0x01, Op.CALLDATALOAD(0)) + Op.STOP + contract_0 = pre.deploy_contract(code=contract_code) + contract_1 = pre.deploy_contract(code=contract_code) + contract_2 = pre.deploy_contract(code=contract_code) + contract_3 = pre.deploy_contract(code=contract_code) + + # For Type 4 (EIP-7702): Alice delegates to Oracle + alice = pre.fund_eoa() + oracle = pre.deploy_contract(code=Op.SSTORE(0x01, 0x05) + Op.STOP) + + # Dummy address to warm in access list + warmed_address = pre.fund_eoa(amount=1) + + # TX1: Type 0 - Legacy transaction + tx_type_0 = Transaction( + ty=0, + sender=sender_0, + to=contract_0, + gas_limit=100_000, + gas_price=10, + data=Hash(0x01), # Value to store + ) + + # TX2: Type 1 - Access List transaction (EIP-2930) + tx_type_1 = Transaction( + ty=1, + sender=sender_1, + to=contract_1, + gas_limit=100_000, + gas_price=10, + data=Hash(0x02), + access_list=[ + AccessList( + address=warmed_address, + storage_keys=[], + ) + ], + ) + + # TX3: Type 2 - EIP-1559 Dynamic fee transaction + tx_type_2 = Transaction( + ty=2, + sender=sender_2, + to=contract_2, + gas_limit=100_000, + max_fee_per_gas=50, + max_priority_fee_per_gas=5, + data=Hash(0x03), + ) + + # TX4: Type 3 - Blob transaction (EIP-4844) + # Blob versioned hashes need KZG version prefix (0x01) + blob_hashes = add_kzg_version([Hash(0xBEEF)], 1) + tx_type_3 = Transaction( + ty=3, + sender=sender_3, + to=contract_3, + gas_limit=100_000, + max_fee_per_gas=50, + max_priority_fee_per_gas=5, + max_fee_per_blob_gas=10, + blob_versioned_hashes=blob_hashes, + data=Hash(0x04), + ) + + # TX5: Type 4 - EIP-7702 Set Code transaction + tx_type_4 = Transaction( + ty=4, + sender=sender_4, + to=alice, + gas_limit=100_000, + max_fee_per_gas=50, + max_priority_fee_per_gas=5, + authorization_list=[ + AuthorizationTuple( + address=oracle, + nonce=0, + signer=alice, + ) + ], + ) + + block = Block( + txs=[tx_type_0, tx_type_1, tx_type_2, tx_type_3, tx_type_4], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + # Type 0 sender + sender_0: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + # Type 1 sender + sender_1: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=2, post_nonce=1)], + ), + # Type 2 sender + sender_2: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=3, post_nonce=1)], + ), + # Type 3 sender + sender_3: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=4, post_nonce=1)], + ), + # Type 4 sender + sender_4: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=5, post_nonce=1)], + ), + # Contract touched by Type 0 + contract_0: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0x01) + ], + ) + ], + ), + # Contract touched by Type 1 + contract_1: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=2, post_value=0x02) + ], + ) + ], + ), + # Note: warmed_address from access_list is NOT in BAL + # because access lists pre-warm but don't record in BAL + # Contract touched by Type 2 + warmed_address: None, # explicit check + contract_2: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=3, post_value=0x03) + ], + ) + ], + ), + # Contract touched by Type 3 + contract_3: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=4, post_value=0x04) + ], + ) + ], + ), + # Alice (Type 4 delegation target, executes oracle code) + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=5, post_nonce=1)], + code_changes=[ + BalCodeChange( + tx_index=5, + new_code=Spec7702.delegation_designation(oracle), + ) + ], + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=5, post_value=0x05) + ], + ) + ], + ), + # Oracle (accessed via delegation) + oracle: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + sender_0: Account(nonce=1), + sender_1: Account(nonce=1), + sender_2: Account(nonce=1), + sender_3: Account(nonce=1), + sender_4: Account(nonce=1), + contract_0: Account(storage={0x01: 0x01}), + contract_1: Account(storage={0x01: 0x02}), + contract_2: Account(storage={0x01: 0x03}), + contract_3: Account(storage={0x01: 0x04}), + alice: Account( + nonce=1, + code=Spec7702.delegation_designation(oracle), + storage={0x01: 0x05}, + ), + }, + ) + + +def test_bal_lexicographic_address_ordering( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL enforces strict lexicographic byte-wise address ordering. + + Addresses: addr_low (0x...01), addr_mid (0x...0100), addr_high (0x01...00). + Endian-trap: addr_endian_low (0x01...02), addr_endian_high (0x02...01). + Contract touches them in reverse order to verify sorting. + + Expected BAL order: low < mid < high < endian_low < endian_high. + Catches endianness bugs in address comparison. + """ + alice = pre.fund_eoa() + + # Create addresses with specific byte patterns for lexicographic testing + # In lexicographic (byte-wise) order: low < mid < high + # addr_low: 0x00...01 (rightmost byte = 0x01) + # addr_mid: 0x00...0100 (second-rightmost byte = 0x01) + # addr_high: 0x01...00 (leftmost byte = 0x01) + addr_low = Address("0x0000000000000000000000000000000000000001") + addr_mid = Address("0x0000000000000000000000000000000000000100") + addr_high = Address("0x0100000000000000000000000000000000000000") + + # Endian-trap addresses: byte-reversals to catch byte-order bugs + # addr_endian_low: 0x01...02 (0x01 at byte 0, 0x02 at byte 19) + # addr_endian_high: 0x02...01 (0x02 at byte 0, 0x01 at byte 19) + # Note: reverse(addr_endian_low) = addr_endian_high + # Correct order: endian_low < endian_high (0x01 < 0x02 at byte 0) + # Reversed bytes would incorrectly get opposite order + addr_endian_low = Address("0x0100000000000000000000000000000000000002") + addr_endian_high = Address("0x0200000000000000000000000000000000000001") + + # Give each address a balance so they exist + addr_balance = 100 + pre[addr_low] = Account(balance=addr_balance) + pre[addr_mid] = Account(balance=addr_balance) + pre[addr_high] = Account(balance=addr_balance) + pre[addr_endian_low] = Account(balance=addr_balance) + pre[addr_endian_high] = Account(balance=addr_balance) + + # Contract that accesses addresses in REVERSE lexicographic order + # to verify sorting is applied correctly + contract_code = ( + Op.BALANCE(addr_high) # Access high first + + Op.POP + + Op.BALANCE(addr_low) # Access low second + + Op.POP + + Op.BALANCE(addr_mid) # Access mid third + + Op.POP + # Access endian-trap addresses in reverse order + + Op.BALANCE(addr_endian_high) # Access endian_high before endian_low + + Op.POP + + Op.BALANCE(addr_endian_low) + + Op.POP + + Op.STOP + ) + + contract = pre.deploy_contract(code=contract_code) + + tx = Transaction( + sender=alice, + to=contract, + gas_limit=1_000_000, + ) + + # BAL must be sorted lexicographically by address bytes + # Order: low < mid < high < endian_low < endian_high + # (sorted by raw address bytes, regardless of access order) + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + contract: BalAccountExpectation.empty(), + # These addresses appear in BAL due to BALANCE access + # The expectation framework verifies correct order + addr_low: BalAccountExpectation.empty(), + addr_mid: BalAccountExpectation.empty(), + addr_high: BalAccountExpectation.empty(), + # Endian-trap addresses: must be sorted correctly despite being + # byte-reversals of each other + addr_endian_low: BalAccountExpectation.empty(), + addr_endian_high: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + contract: Account(), + addr_low: Account(balance=addr_balance), + addr_mid: Account(balance=addr_balance), + addr_high: Account(balance=addr_balance), + addr_endian_low: Account(balance=addr_balance), + addr_endian_high: Account(balance=addr_balance), + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py index e9bed5df85..a0a6a1e121 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_eip7702.py @@ -16,6 +16,7 @@ BlockchainTestFiller, Op, Transaction, + Withdrawal, ) from ...prague.eip7702_set_code_tx.spec import Spec as Spec7702 @@ -828,3 +829,245 @@ def test_bal_7702_double_auth_swap( relayer: Account(nonce=1), }, ) + + +def test_bal_selfdestruct_to_7702_delegation( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with SELFDESTRUCT to 7702 delegated account. + + Tx1: Alice delegates to Oracle. + Tx2: Victim (balance=100) selfdestructs to Alice. + SELFDESTRUCT transfers balance without executing recipient code. + + Expected BAL: + - Alice tx1: code_changes (delegation), nonce_changes + - Alice tx2: balance_changes (+100) + - Victim tx2: balance_changes (100→0) + - Oracle: MUST NOT appear (SELFDESTRUCT doesn't execute recipient code) + """ + # Alice (EOA) will receive delegation then receive selfdestruct balance + # Use explicit initial balance for clarity + alice_initial_balance = 10**18 # 1 ETH default + alice = pre.fund_eoa(amount=alice_initial_balance) + bob = pre.fund_eoa(amount=0) # Just to be the recipient of tx + + # Oracle contract that Alice will delegate to + oracle = pre.deploy_contract(code=Op.SSTORE(0x01, 0x42) + Op.STOP) + + victim_balance = 100 + + # Victim contract that selfdestructs to Alice + victim = pre.deploy_contract( + code=Op.SELFDESTRUCT(alice), + balance=victim_balance, + ) + + # Relayer for tx1 (delegation) + relayer = pre.fund_eoa() + + # Tx1: Alice authorizes delegation to Oracle + tx1 = Transaction( + sender=relayer, + to=bob, + value=10, + gas_limit=1_000_000, + gas_price=0xA, + authorization_list=[ + AuthorizationTuple( + address=oracle, + nonce=0, + signer=alice, + ) + ], + ) + + # Caller contract that triggers selfdestruct on victim + caller = pre.deploy_contract(code=Op.CALL(100_000, victim, 0, 0, 0, 0, 0)) + + # Tx2: Trigger selfdestruct on victim (victim sends balance to Alice) + tx2 = Transaction( + nonce=1, + sender=relayer, + to=caller, + gas_limit=1_000_000, + gas_price=0xA, + ) + + alice_final_balance = alice_initial_balance + victim_balance + + account_expectations = { + alice: BalAccountExpectation( + # tx1: nonce change for auth, code change for delegation + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + code_changes=[ + BalCodeChange( + tx_index=1, + new_code=Spec7702.delegation_designation(oracle), + ) + ], + # tx2: balance change from selfdestruct + balance_changes=[ + BalBalanceChange(tx_index=2, post_balance=alice_final_balance) + ], + ), + bob: BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + ), + relayer: BalAccountExpectation( + nonce_changes=[ + BalNonceChange(tx_index=1, post_nonce=1), + BalNonceChange(tx_index=2, post_nonce=2), + ], + ), + caller: BalAccountExpectation.empty(), + # Victim (selfdestructing contract): balance changes to 0 + # Explicitly verify ALL fields to avoid false positives + victim: BalAccountExpectation( + nonce_changes=[], # Contract nonce unchanged + balance_changes=[BalBalanceChange(tx_index=2, post_balance=0)], + code_changes=[], # Code unchanged (post-Cancun SELFDESTRUCT) + storage_changes=[], # No storage changes + storage_reads=[], # No storage reads + ), + # Oracle MUST NOT appear in tx2 - SELFDESTRUCT doesn't execute + # recipient code, so delegation target is never accessed + oracle: None, + } + + block = Block( + txs=[tx1, tx2], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + post = { + alice: Account( + nonce=1, + code=Spec7702.delegation_designation(oracle), + balance=alice_final_balance, + ), + bob: Account(balance=10), + relayer: Account(nonce=2), + # Victim still exists but with 0 balance (post-Cancun SELFDESTRUCT) + victim: Account(balance=0), + } + + blockchain_test( + pre=pre, + blocks=[block], + post=post, + ) + + +GWEI = 10**9 + + +def test_bal_withdrawal_to_7702_delegation( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with withdrawal to 7702 delegated account. + + Tx1: Alice delegates to Oracle. Withdrawal: 10 gwei to Alice. + Withdrawals credit balance without executing code. + + Expected BAL: + - Alice tx1: code_changes (delegation), nonce_changes + - Alice tx2: balance_changes (+10 gwei) + - Oracle: MUST NOT appear (withdrawals don't execute recipient code) + """ + # Alice (EOA) will receive delegation then receive withdrawal + alice_initial_balance = 10**18 # 1 ETH default + alice = pre.fund_eoa(amount=alice_initial_balance) + bob = pre.fund_eoa(amount=0) # Recipient of tx value + + # Oracle contract that Alice will delegate to + # If delegation were followed, this would write to storage + oracle = pre.deploy_contract(code=Op.SSTORE(0x01, 0x42) + Op.STOP) + + # Relayer for the delegation tx + relayer = pre.fund_eoa() + + withdrawal_amount_gwei = 10 + + # Tx1: Alice authorizes delegation to Oracle + tx1 = Transaction( + sender=relayer, + to=bob, + value=10, + gas_limit=1_000_000, + gas_price=0xA, + authorization_list=[ + AuthorizationTuple( + address=oracle, + nonce=0, + signer=alice, + ) + ], + ) + + alice_final_balance = alice_initial_balance + ( + withdrawal_amount_gwei * GWEI + ) + + account_expectations = { + alice: BalAccountExpectation( + # tx1: nonce change for auth, code change for delegation + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + code_changes=[ + BalCodeChange( + tx_index=1, + new_code=Spec7702.delegation_designation(oracle), + ) + ], + # tx2 (withdrawal): balance change + balance_changes=[ + BalBalanceChange(tx_index=2, post_balance=alice_final_balance) + ], + ), + bob: BalAccountExpectation( + balance_changes=[BalBalanceChange(tx_index=1, post_balance=10)] + ), + relayer: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + # Oracle MUST NOT appear - withdrawals don't execute recipient code, + # so delegation target is never accessed + oracle: None, + } + + block = Block( + txs=[tx1], + withdrawals=[ + Withdrawal( + index=0, + validator_index=0, + address=alice, + amount=withdrawal_amount_gwei, + ) + ], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + + post = { + alice: Account( + nonce=1, + code=Spec7702.delegation_designation(oracle), + balance=alice_final_balance, + ), + bob: Account(balance=10), + relayer: Account(nonce=1), + } + + blockchain_test( + pre=pre, + blocks=[block], + post=post, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py index bae1544eee..1c12cec89e 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_opcodes.py @@ -1266,3 +1266,617 @@ def test_bal_create_contract_init_revert( created_address: Account.NONEXISTENT, }, ) + + +def test_bal_call_revert_insufficient_funds( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with CALL failure due to insufficient balance (not OOG). + + Contract (balance=100): SLOAD(0x01)→CALL(target, value=1000)→SSTORE(0x02). + CALL fails because 1000 > 100. Target is 0xDEAD. + + Expected BAL: + - Contract: storage_reads [0x01], storage_changes slot 0x02 (value=0) + - Target: appears in BAL (accessed before balance check fails) + """ + alice = pre.fund_eoa() + + contract_balance = 100 + transfer_amount = 1000 # More than contract has + + # Target address that should be warmed but not receive funds + # Give it a small balance so it's not considered "empty" and pruned + target_balance = 1 + target_address = pre.fund_eoa(amount=target_balance) + + # Contract that: + # 1. SLOAD slot 0x01 + # 2. CALL target with value=1000 (will fail - insufficient funds) + # 3. SSTORE slot 0x02 with CALL result (0 = failure) + contract_code = ( + Op.SLOAD(0x01) # Read from slot 0x01, push to stack + + Op.POP # Discard value + # CALL(gas, addr, value, argsOffset, argsSize, retOffset, retSize) + + Op.CALL(100_000, target_address, transfer_amount, 0, 0, 0, 0) + # CALL result is on stack (0 = failure, 1 = success) + # Stack: [result] + + Op.PUSH1(0x02) # Push slot number + # Stack: [0x02, result] + + Op.SSTORE # SSTORE pops slot (0x02), then value (result) + + Op.STOP + ) + + contract = pre.deploy_contract( + code=contract_code, + balance=contract_balance, + storage={ + 0x02: 0xDEAD + }, # Non-zero initial value so SSTORE(0) is a change + ) + + tx = Transaction( + sender=alice, + to=contract, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + contract: BalAccountExpectation( + # Storage read for slot 0x01 + storage_reads=[0x01], + # Storage change for slot 0x02 (CALL result = 0) + storage_changes=[ + BalStorageSlot( + slot=0x02, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0) + ], + ) + ], + ), + # Target appears in BAL - accessed before balance check fails + target_address: BalAccountExpectation.empty(), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + contract: Account( + balance=contract_balance, # Unchanged - transfer failed + storage={0x02: 0}, # CALL returned 0 (failure) + ), + target_address: Account(balance=target_balance), # Unchanged + }, + ) + + +def test_bal_create_selfdestruct_to_self_with_call( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with init code that CALLs Oracle, writes storage, then + SELFDESTRUCTs to self. + + Factory CREATE2(endowment=100). + Init: CALL(Oracle)→SSTORE(0x01)→SELFDESTRUCT(SELF). + + Expected BAL: + - Factory: nonce_changes, balance_changes (loses 100) + - Oracle: storage_changes slot 0x01 + - Created address: storage_reads [0x01] (aborted write→read), + MUST NOT have nonce/code/storage/balance changes (ephemeral) + """ + alice = pre.fund_eoa() + factory_balance = 1000 + + # Oracle contract that writes to slot 0x01 when called + oracle_code = Op.SSTORE(0x01, 0x42) + Op.STOP + oracle = pre.deploy_contract(code=oracle_code) + + endowment = 100 + + # Init code that: + # 1. Calls Oracle (which writes to its slot 0x01) + # 2. Writes 0x42 to own slot 0x01 + # 3. Selfdestructs to self + initcode_runtime = ( + # CALL(gas, Oracle, value=0, ...) + Op.CALL(100_000, oracle, 0, 0, 0, 0, 0) + + Op.POP + # Write to own storage slot 0x01 + + Op.SSTORE(0x01, 0x42) + # SELFDESTRUCT to self (ADDRESS returns own address) + + Op.SELFDESTRUCT(Op.ADDRESS) + ) + init_code = Initcode(deploy_code=Op.STOP, initcode_prefix=initcode_runtime) + init_code_bytes = bytes(init_code) + init_code_size = len(init_code_bytes) + + # Factory code with embedded initcode (no template contract needed) + # Structure: [execution code] [initcode bytes] + # CODECOPY copies initcode from factory's own code to memory + # + # Two-pass approach: build with placeholder, measure, rebuild + placeholder_offset = 0xFF # Placeholder (same byte size as final value) + factory_execution_template = ( + Op.CODECOPY(0, placeholder_offset, init_code_size) + + Op.SSTORE( + 0x00, + Op.CREATE2( + value=endowment, + offset=0, + size=init_code_size, + salt=0, + ), + ) + + Op.STOP + ) + # Measure execution code size + execution_code_size = len(bytes(factory_execution_template)) + + # Rebuild with actual offset value + factory_execution = ( + Op.CODECOPY(0, execution_code_size, init_code_size) + + Op.SSTORE( + 0x00, + Op.CREATE2( + value=endowment, + offset=0, + size=init_code_size, + salt=0, + ), + ) + + Op.STOP + ) + # Combine execution code with embedded initcode + factory_code = bytes(factory_execution) + init_code_bytes + + factory = pre.deploy_contract(code=factory_code, balance=factory_balance) + + # Calculate the CREATE2 target address + created_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=init_code_bytes, + opcode=Op.CREATE2, + ) + + tx = Transaction( + sender=alice, + to=factory, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + factory: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + # Balance changes: loses endowment (100) + balance_changes=[ + BalBalanceChange( + tx_index=1, + post_balance=factory_balance - endowment, + ) + ], + ), + # Oracle: storage changes for slot 0x01 + oracle: BalAccountExpectation( + storage_changes=[ + BalStorageSlot( + slot=0x01, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0x42) + ], + ) + ], + ), + # Created address: ephemeral (created and destroyed same tx) + # - storage_reads for slot 0x01 (aborted write becomes read) + # - NO nonce/code/storage/balance changes + created_address: BalAccountExpectation( + storage_reads=[0x01], + storage_changes=[], + nonce_changes=[], + code_changes=[], + balance_changes=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + factory: Account(nonce=2, balance=factory_balance - endowment), + oracle: Account(storage={0x01: 0x42}), + # Created address doesn't exist - destroyed in same tx + created_address: Account.NONEXISTENT, + }, + ) + + +def test_bal_create2_collision( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with CREATE2 collision against pre-existing contract. + + Pre-existing contract has code=STOP, nonce=1. + Factory (nonce=1, slot[0]=0xDEAD) executes CREATE2 targeting it. + + Expected BAL: + - Factory: nonce_changes (1→2), storage_changes slot 0 (0xDEAD→0) + - Collision address: empty (accessed during collision check) + - Collision address MUST NOT have nonce_changes or code_changes + """ + alice = pre.fund_eoa() + + # Init code that deploys simple STOP contract + init_code = Initcode(deploy_code=Op.STOP) + init_code_bytes = bytes(init_code) + + # Factory code: CREATE2 and store result in slot 0 + factory_code = ( + # Push init code to memory + Op.MSTORE(0, Op.PUSH32(init_code_bytes)) + # SSTORE(0, CREATE2(...)) - stores CREATE2 result in slot 0 + + Op.SSTORE( + 0x00, + Op.CREATE2( + value=0, + offset=32 - len(init_code_bytes), + size=len(init_code_bytes), + salt=0, + ), + ) + + Op.STOP + ) + + # Deploy factory - it starts with nonce=1 by default + factory = pre.deploy_contract( + code=factory_code, + storage={0x00: 0xDEAD}, # Initial value to prove SSTORE works + ) + + # Calculate the CREATE2 target address + collision_address = compute_create_address( + address=factory, + nonce=1, + salt=0, + initcode=init_code_bytes, + opcode=Op.CREATE2, + ) + + # Set up the collision by pre-populating the target address + # This contract has code (STOP) and nonce=1, causing collision + pre[collision_address] = Account( + code=Op.STOP, + nonce=1, + ) + + tx = Transaction( + sender=alice, + to=factory, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + factory: BalAccountExpectation( + # Nonce incremented 1→2 even on failed CREATE2 + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=2)], + # Storage changes: slot 0 = 0xDEAD → 0 (CREATE2 returned 0) + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0) + ], + ) + ], + ), + # Collision address: empty (accessed but no state changes) + # Explicitly verify ALL fields are empty + collision_address: BalAccountExpectation( + nonce_changes=[], # MUST NOT have nonce changes + balance_changes=[], # MUST NOT have balance changes + code_changes=[], # MUST NOT have code changes + storage_changes=[], # MUST NOT have storage changes + storage_reads=[], # MUST NOT have storage reads + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + factory: Account(nonce=2, storage={0x00: 0}), + # Collision address unchanged - contract still exists + collision_address: Account(code=bytes(Op.STOP), nonce=1), + }, + ) + + +def test_bal_transient_storage_not_tracked( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL excludes EIP-1153 transient storage (TSTORE/TLOAD). + + Contract: TSTORE(0x01, 0x42)→TLOAD(0x01)→SSTORE(0x02, result). + + Expected BAL: + - storage_changes: slot 0x02 (persistent) + - MUST NOT include slot 0x01 (transient storage not persisted) + """ + alice = pre.fund_eoa() + + # Contract that uses transient storage then persists to regular storage + contract_code = ( + # TSTORE slot 0x01 with value 0x42 (transient storage) + Op.TSTORE(0x01, 0x42) + # TLOAD slot 0x01 (transient storage read) + + Op.TLOAD(0x01) + # Result (0x42) is on stack, store it in persistent slot 0x02 + + Op.PUSH1(0x02) + + Op.SSTORE # SSTORE pops slot (0x02), then value (0x42) + + Op.STOP + ) + + contract = pre.deploy_contract(code=contract_code) + + tx = Transaction( + sender=alice, + to=contract, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + contract: BalAccountExpectation( + # Persistent storage change for slot 0x02 + storage_changes=[ + BalStorageSlot( + slot=0x02, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0x42) + ], + ) + ], + # MUST NOT include slot 0x01 in storage_reads + # Transient storage operations don't pollute BAL + storage_reads=[], + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + contract: Account(storage={0x02: 0x42}), + }, + ) + + +def test_bal_selfdestruct_to_precompile( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with SELFDESTRUCT to precompile (ecrecover 0x01). + + Victim (balance=100) selfdestructs to precompile 0x01. + + Expected BAL: + - Victim: balance_changes (100→0) + - Precompile 0x01: balance_changes (0→100), no code/nonce changes + """ + alice = pre.fund_eoa() + + contract_balance = 100 + ecrecover_precompile = Address(1) # 0x0000...0001 + + # Contract that selfdestructs to ecrecover precompile + victim_code = Op.SELFDESTRUCT(ecrecover_precompile) + + victim = pre.deploy_contract(code=victim_code, balance=contract_balance) + + # Caller that triggers the selfdestruct + caller_code = Op.CALL(100_000, victim, 0, 0, 0, 0, 0) + Op.STOP + caller = pre.deploy_contract(code=caller_code) + + tx = Transaction( + sender=alice, + to=caller, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + caller: BalAccountExpectation.empty(), + # Victim (selfdestructing contract): balance changes 100→0 + # Explicitly verify ALL fields to avoid false positives + victim: BalAccountExpectation( + nonce_changes=[], # Contract nonce unchanged + balance_changes=[ + BalBalanceChange(tx_index=1, post_balance=0) + ], + code_changes=[], # Code unchanged (post-Cancun) + storage_changes=[], # No storage changes + storage_reads=[], # No storage reads + ), + # Precompile receives selfdestruct balance + # Explicitly verify ALL fields to avoid false positives + ecrecover_precompile: BalAccountExpectation( + nonce_changes=[], # MUST NOT have nonce changes + balance_changes=[ + BalBalanceChange( + tx_index=1, post_balance=contract_balance + ) + ], + code_changes=[], # MUST NOT have code changes + storage_changes=[], # MUST NOT have storage changes + storage_reads=[], # MUST NOT have storage reads + ), + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + caller: Account(), + # Victim still exists with 0 balance (post-Cancun SELFDESTRUCT) + victim: Account(balance=0), + # Precompile has received the balance + ecrecover_precompile: Account(balance=contract_balance), + }, + ) + + +def test_bal_create_early_failure( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test BAL with CREATE failure due to insufficient endowment. + + Factory (balance=50) attempts CREATE(value=100). + Fails before nonce increment (before track_address). + Distinct from collision where address IS accessed. + + Expected BAL: + - Alice: nonce_changes + - Factory: storage_changes slot 0 (0xDEAD→0), NO nonce_changes + - Contract address: MUST NOT appear (never accessed) + """ + alice = pre.fund_eoa() + + factory_balance = 50 + endowment = 100 # More than factory has + + # Simple init code that deploys STOP + init_code = Initcode(deploy_code=Op.STOP) + init_code_bytes = bytes(init_code) + + # Factory code: CREATE(value=endowment) and store result in slot 0 + factory_code = ( + # Push init code to memory + Op.MSTORE(0, Op.PUSH32(init_code_bytes)) + # SSTORE(0, CREATE(value, offset, size)) + + Op.SSTORE( + 0x00, + Op.CREATE( + value=endowment, # 100 > 50, will fail + offset=32 - len(init_code_bytes), + size=len(init_code_bytes), + ), + ) + + Op.STOP + ) + + # Deploy factory with insufficient balance for the CREATE endowment + factory = pre.deploy_contract( + code=factory_code, + balance=factory_balance, + storage={0x00: 0xDEAD}, # Initial value to prove SSTORE works + ) + + # Calculate what the contract address WOULD be (but it won't be created) + would_be_contract_address = compute_create_address( + address=factory, nonce=1 + ) + + tx = Transaction( + sender=alice, + to=factory, + gas_limit=1_000_000, + ) + + block = Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations={ + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + factory: BalAccountExpectation( + # NO nonce_changes - CREATE failed before increment_nonce + nonce_changes=[], + # Storage changes: slot 0 = 0xDEAD → 0 (CREATE returned 0) + storage_changes=[ + BalStorageSlot( + slot=0x00, + slot_changes=[ + BalStorageChange(tx_index=1, post_value=0) + ], + ) + ], + ), + # Contract address MUST NOT appear in BAL - never accessed + # (CREATE failed before track_address was called) + would_be_contract_address: None, + } + ), + ) + + blockchain_test( + pre=pre, + blocks=[block], + post={ + alice: Account(nonce=1), + # Factory nonce unchanged (still 1), balance unchanged + factory: Account( + nonce=1, balance=factory_balance, storage={0x00: 0} + ), + # Contract was never created + would_be_contract_address: Account.NONEXISTENT, + }, + ) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 9ddca8a362..07d184d957 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -54,10 +54,10 @@ | `test_bal_oog_7702_delegated_warm_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when first account is warm, second is cold | Alice calls warm delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (warm load succeeds) but **MUST NOT** include `TargetContract` (cold load fails due to OOG) | 🟡 Planned | | `test_bal_multiple_balance_changes_same_account` | Ensure BAL tracks multiple balance changes to same account across transactions | Alice funds Bob (starts at 0) in tx0 with exact amount needed. Bob spends everything in tx1 to Charlie. Bob's balance: 0 → funding_amount → 0 | BAL **MUST** include Bob with two `balance_changes`: one at txIndex=1 (receives funds) and one at txIndex=2 (balance returns to 0). This tests balance tracking across two transactions. | ✅ Completed | | `test_bal_multiple_storage_writes_same_slot` | Ensure BAL tracks multiple writes to same storage slot across transactions | Alice calls contract 3 times in same block. Contract increments slot 1 on each call: 0 → 1 → 2 → 3 | BAL **MUST** include contract with slot 1 having three `slot_changes`: txIndex=1 (value 1), txIndex=2 (value 2), txIndex=3 (value 3). Each transaction's write must be recorded separately. | ✅ Completed | -| `test_bal_create_transaction_empty_code` | Ensure BAL does not record spurious code changes for CREATE transaction deploying empty code | Alice sends CREATE transaction with empty initcode (deploys code `b""`). Contract address gets nonce = 1 and code = `b""`. | BAL **MUST** include Alice with `nonce_changes` and created contract with `nonce_changes` but **MUST NOT** include `code_changes` for contract (setting `b"" -> b""` is net-zero). | ✅ Completed | | `test_bal_cross_tx_storage_revert_to_zero` | Ensure BAL captures storage changes when tx2 reverts slot back to original value (blobhash regression test) | Alice sends tx1 writing slot 0=0xABCD (from 0x0), then tx2 writing slot 0=0x0 (back to original) | BAL **MUST** include contract with slot 0 having two `slot_changes`: txIndex=1 (0xABCD) and txIndex=2 (0x0). Cross-transaction net-zero **MUST NOT** be filtered. | ✅ Completed | | `test_bal_create_contract_init_revert` | Ensure BAL correctly handles CREATE when parent call reverts | Caller calls factory, factory executes CREATE (succeeds), then factory REVERTs rolling back the CREATE | BAL **MUST** include Alice with `nonce_changes`. Caller and factory with no changes (reverted). Created contract address appears in BAL but **MUST NOT** have `nonce_changes` or `code_changes` (CREATE was rolled back). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_create_oog_code_deposit` | Ensure BAL correctly handles CREATE OOG during code deposit | Alice calls factory contract that executes CREATE with init code returning 10,000 bytes. Transaction has insufficient gas for code deposit. Factory nonce increments, CREATE returns 0 and stores in slot 1. | BAL **MUST** include Alice with `nonce_changes`. Factory with `nonce_changes` (incremented by CREATE) and `storage_changes` (slot 1 = 0). Contract address with empty changes (read during collision check). **MUST NOT** include nonce or code changes for contract address (rolled back on OOG). Contract address **MUST NOT** exist in post-state. | ✅ Completed | +| `test_bal_create_early_failure` | Ensure BAL correctly handles CREATE that fails before accessing contract address | Factory (balance=50) attempts CREATE(value=100). CREATE fails due to insufficient endowment (100 > 50). Factory stores CREATE result (0) in slot 0. | BAL **MUST** include Alice with `nonce_changes`. Factory with `storage_changes` (slot 0 = 0) but **MUST NOT** have `nonce_changes` (CREATE failed before nonce increment). Contract address **MUST NOT** appear in BAL (never accessed - CREATE failed before `track_address`). This is distinct from collision/OOG failures where contract address IS in BAL. | ✅ Completed | | `test_bal_invalid_missing_nonce` | Verify clients reject blocks with BAL missing required nonce changes | Alice sends transaction to Bob; BAL modifier removes Alice's nonce change entry | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate that all sender accounts have nonce changes recorded. | ✅ Completed | | `test_bal_invalid_nonce_value` | Verify clients reject blocks with incorrect nonce values in BAL | Alice sends transaction to Bob; BAL modifier changes Alice's nonce to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate nonce values match actual state transitions. | ✅ Completed | | `test_bal_invalid_storage_value` | Verify clients reject blocks with incorrect storage values in BAL | Alice calls contract that writes to storage; BAL modifier changes storage value to incorrect value | Block **MUST** be rejected with `INVALID_BLOCK_ACCESS_LIST` exception. Clients **MUST** validate storage change values match actual state transitions. | ✅ Completed | @@ -89,10 +89,19 @@ | `test_bal_nonexistent_account_access_value_transfer` | Ensure BAL captures non-existent account accessed via CALL/CALLCODE with value transfers | Alice calls `Oracle` contract which uses `CALL` or `CALLCODE` on non-existent account Bob. Tests both zero and positive value transfers. | BAL **MUST** include Alice with `nonce_changes`. For CALL with positive value: `Oracle` with `balance_changes` (loses value), Bob with `balance_changes` (receives value). For CALLCODE with value or zero value transfers: `Oracle` and Bob with empty changes (CALLCODE self-transfer = net zero). | ✅ Completed | | `test_bal_storage_write_read_same_frame` | Ensure BAL captures write precedence over read in same call frame (writes shadow reads) | Alice calls `Oracle` which writes (`SSTORE`) value `0x42` to slot `0x01`, then reads (`SLOAD`) from slot `0x01` in the same call frame | BAL **MUST** include `Oracle` with slot `0x01` in `storage_changes` showing final value `0x42`. Slot `0x01` **MUST NOT** appear in `storage_reads` (write shadows the subsequent read in same frame). | ✅ Completed | | `test_bal_storage_write_read_cross_frame` | Ensure BAL captures write precedence over read across call frames (writes shadow reads cross-frame) | Alice calls `Oracle`. First call reads slot `0x01` (sees initial value), writes `0x42` to slot `0x01`, then calls itself (via `CALL`, `DELEGATECALL`, or `CALLCODE`). Second call reads slot `0x01` (sees `0x42`) and exits. | BAL **MUST** include `Oracle` with slot `0x01` in `storage_changes` showing final value `0x42`. Slot `0x01` **MUST NOT** appear in `storage_reads` (write shadows both the read before it in same frame and the read in the recursive call). | ✅ Completed | -| `test_bal_create_transaction_empty_code` | Ensure BAL does not record spurious code changes for CREATE transaction deploying empty code | Alice sends CREATE transaction with empty initcode (deploys code `b""`). Contract address gets nonce = 1 and code = `b""`. | BAL **MUST** include Alice with `nonce_changes` and created contract with `nonce_changes` but **MUST NOT** include `code_changes` for contract. | ✅ Completed | +| `test_bal_create_transaction_empty_code` | Ensure BAL does not record spurious code changes for CREATE transaction deploying empty code | Alice sends CREATE transaction with empty initcode (deploys code `b""`). Contract address gets nonce = 1 and code = `b""`. | BAL **MUST** include Alice with `nonce_changes` and created contract with `nonce_changes` but **MUST NOT** include `code_changes` for contract (setting `b"" -> b""` is net-zero). | ✅ Completed | +| `test_bal_cross_block_precompile_state_leak` | Ensure internal EVM state for precompile handling does not leak between blocks | Block 1: Alice calls RIPEMD-160 (0x03) with zero value (RIPEMD-160 must be pre-funded). Block 2: Bob's transaction triggers an exception (stack underflow). | BAL for Block 1 **MUST** include RIPEMD-160. BAL for Block 2 **MUST NOT** include RIPEMD-160 (never accessed in Block 2). Internal state from Parity Touch Bug (EIP-161) handling must be reset between blocks. | ✅ Completed | +| `test_bal_all_transaction_types` | Ensure BAL correctly captures state changes from all transaction types in a single block | Single block with 5 transactions: Type 0 (Legacy), Type 1 (EIP-2930 Access List), Type 2 (EIP-1559), Type 3 (EIP-4844 Blob), Type 4 (EIP-7702 Set Code). Each tx writes to contract storage. Note: Access list addresses are pre-warmed but NOT recorded in BAL (no state access). | BAL **MUST** include: (1) All 5 senders with `nonce_changes`. (2) Contracts 0-3 with `storage_changes`. (3) Alice (7702 target) with `nonce_changes`, `code_changes` (delegation), `storage_changes`. (4) Oracle (delegation source) with empty changes. | ✅ Completed | +| `test_bal_create2_collision` | Ensure BAL handles CREATE2 address collision correctly | Factory contract (nonce=1, storage slot 0=0xDEAD) executes `CREATE2(salt=0, initcode)` targeting address that already has `code=STOP, nonce=1`. Pre-deploy contract at calculated CREATE2 target address before factory deployment. | BAL **MUST** include: (1) Factory with `nonce_changes` (1→2, incremented even on failed CREATE2), `storage_changes` for slot 0 (0xDEAD→0, stores failure). (2) Collision address with empty changes (accessed during collision check, no state changes). CREATE2 returns 0. Collision address **MUST NOT** have `nonce_changes` or `code_changes`. | ✅ Completed | +| `test_bal_create_selfdestruct_to_self_with_call` | Ensure BAL handles init code that calls external contract then selfdestructs to itself | Factory executes `CREATE2` with endowment=100. Init code (embedded in factory via CODECOPY): (1) `CALL(Oracle, 0)` - Oracle writes to its storage slot 0x01. (2) `SSTORE(0x01, 0x42)` - write to own storage. (3) `SELFDESTRUCT(SELF)` - selfdestruct to own address. Contract created and destroyed in same tx. | BAL **MUST** include: (1) Factory with `nonce_changes`, `balance_changes` (loses 100). (2) Oracle with `storage_changes` for slot 0x01 (external call succeeded). (3) Created address with `storage_reads` for slot 0x01 (aborted write becomes read) - **MUST NOT** have `nonce_changes`, `code_changes`, `storage_changes`, or `balance_changes` (ephemeral contract, balance burned via SELFDESTRUCT to self). | ✅ Completed | +| `test_bal_selfdestruct_to_7702_delegation` | Ensure BAL correctly handles SELFDESTRUCT to a 7702 delegated account (no code execution on recipient) | Tx1: Alice authorizes delegation to Oracle (sets code to `0xef0100\|\|Oracle`). Tx2: Victim contract (balance=100) executes `SELFDESTRUCT(Alice)`. Two separate transactions in same block. Note: Alice starts with initial balance which accumulates with selfdestruct. | BAL **MUST** include: (1) Alice at tx_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at tx_index=2 with `balance_changes` (receives selfdestruct). (3) Victim at tx_index=2 with `balance_changes` (100→0). **Oracle MUST NOT appear in tx2** - per EVM spec, SELFDESTRUCT transfers balance without executing recipient code, so delegation target is never accessed. | ✅ Completed | +| `test_bal_call_revert_insufficient_funds` | Ensure BAL handles CALL failure due to insufficient balance (not OOG) | Contract (balance=100, storage slot 0x02=0xDEAD) executes: `SLOAD(0x01), CALL(target, value=1000), SSTORE(0x02, result)`. CALL fails because 1000 > 100. Target address 0xDEAD (pre-existing with non-zero balance to avoid pruning). Note: slot 0x02 must start non-zero so SSTORE(0) is a change. | BAL **MUST** include: (1) Contract with `storage_reads` for slot 0x01, `storage_changes` for slot 0x02 (value=0, CALL returned failure). (2) Target (0xDEAD) **MUST** appear in BAL with empty changes - target is accessed before balance check fails. | ✅ Completed | +| `test_bal_lexicographic_address_ordering` | Ensure BAL enforces strict lexicographic byte-wise ordering | Pre-fund three addresses with specific byte patterns: `addr_low = 0x0000...0001`, `addr_mid = 0x0000...0100`, `addr_high = 0x0100...0000`. Contract touches them in reverse order: `BALANCE(addr_high), BALANCE(addr_low), BALANCE(addr_mid)`. Additionally, include two endian-trap addresses that are byte-reversals of each other: `addr_endian_low = 0x0100000000000000000000000000000000000002`, `addr_endian_high = 0x0200000000000000000000000000000000000001`. Note: `reverse(addr_endian_low) = addr_endian_high`. Correct lexicographic order: `addr_endian_low < addr_endian_high` (0x01 < 0x02 at byte 0). If implementation incorrectly reverses bytes before comparing, it would get `addr_endian_low > addr_endian_high` (wrong). | BAL account list **MUST** be sorted lexicographically by address bytes: `addr_low` < `addr_mid` < `addr_high` < `addr_endian_low` < `addr_endian_high`, regardless of access order. The endian-trap addresses specifically catch byte-reversal bugs where addresses are compared with wrong byte order. Complements `test_bal_invalid_account_order` which tests rejection; this tests correct generation. | ✅ Completed | +| `test_bal_transient_storage_not_tracked` | Ensure BAL excludes EIP-1153 transient storage operations | Contract executes: `TSTORE(0x01, 0x42)` (transient write), `TLOAD(0x01)` (transient read), `SSTORE(0x02, result)` (persistent write using transient value). | BAL **MUST** include slot 0x02 in `storage_changes` (persistent storage was modified). BAL **MUST NOT** include slot 0x01 in `storage_reads` or `storage_changes` (transient storage is not persisted, not needed for stateless execution). This verifies TSTORE/TLOAD don't pollute BAL. | ✅ Completed | +| `test_bal_selfdestruct_to_precompile` | Ensure BAL captures SELFDESTRUCT with precompile as beneficiary | Caller triggers victim contract (balance=100) to execute `SELFDESTRUCT(0x0000...0001)` (ecrecover precompile). Precompile starts with balance=0. | BAL **MUST** include: (1) Contract with `balance_changes` (100→0, loses balance to selfdestruct). (2) Precompile address 0x01 with `balance_changes` (0→100, receives selfdestruct balance). Precompile **MUST NOT** have `code_changes` or `nonce_changes`. This complements `test_bal_withdrawal_to_precompiles` (withdrawal) and `test_bal_precompile_funded` (tx value). | ✅ Completed | +| `test_bal_self_destruct_oog` | Ensure BAL correctly tracks SELFDESTRUCT beneficiary based on gas boundaries | Alice calls `Caller` contract which CALLs `SelfDestructContract` with precisely controlled gas. `SelfDestructContract` attempts SELFDESTRUCT to new account `Beneficiary`. Static gas = G_VERY_LOW + G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS. Parameterized: (1) OOG before state access (gas = static - 1), (2) OOG after state access (gas = static, but insufficient for G_NEW_ACCOUNT). | For OOG before state access: BAL **MUST NOT** include `Beneficiary` (no state access occurred). For OOG after state access: BAL **MUST** include `Beneficiary` with empty changes (state was accessed before G_NEW_ACCOUNT check failed). Both cases: Alice with `nonce_changes`, `Caller` and `SelfDestructContract` with empty changes. Contract balance unchanged. | ✅ Completed | +| `test_bal_withdrawal_to_7702_delegation` | Ensure BAL correctly handles withdrawal to a 7702 delegated account (no code execution on recipient) | Tx1: Alice authorizes delegation to Oracle (sets code to `0xef0100\|\|Oracle`). Withdrawal: 10 gwei sent to Alice. Single block with tx + withdrawal. | BAL **MUST** include: (1) Alice at tx_index=1 with `code_changes` (delegation), `nonce_changes`. (2) Alice at tx_index=2 with `balance_changes` (receives withdrawal). **Oracle MUST NOT appear** - withdrawals credit balance without executing recipient code, so delegation target is never accessed. This complements `test_bal_selfdestruct_to_7702_delegation` (selfdestruct) and `test_bal_withdrawal_no_evm_execution` (withdrawal to contract). | ✅ Completed | | `test_init_collision_create_tx` | Ensure BAL tracks CREATE collisions correctly (pre-Amsterdam test with BAL) | CREATE transaction targeting address with existing storage aborts | BAL **MUST** show empty expectations for collision address (no changes occur due to abort) | ✅ Completed | | `test_call_to_pre_authorized_oog` | Ensure BAL handles OOG during EIP-7702 delegation access (pre-Amsterdam test with BAL) | Call to delegated account that OOGs before accessing delegation contract | BAL **MUST** include auth_signer (code read for delegation check) but **MUST NOT** include delegation contract (OOG before access) | ✅ Completed | | `test_selfdestruct_created_in_same_tx_with_revert` | Ensure BAL tracks selfdestruct with revert correctly (pre-Amsterdam test with BAL) | Contract created and selfdestructed in same tx with nested revert | BAL **MUST** track storage reads and balance changes for selfdestruct even with reverts | ✅ Completed | | `test_value_transfer_gas_calculation` | Ensure BAL correctly tracks OOG scenarios for CALL/CALLCODE/DELEGATECALL/STATICCALL (pre-Amsterdam test with BAL) | Nested calls with precise gas limits to test OOG behavior. For CALL with OOG: target account is read. For CALLCODE/DELEGATECALL/STATICCALL with OOG: target account **NOT** read (OOG before state access) | For CALL: target in BAL even with OOG. For CALLCODE/DELEGATECALL/STATICCALL: target **NOT** in BAL when OOG (state access deferred until after gas check) | ✅ Completed | -| `test_bal_cross_block_precompile_state_leak` | Ensure internal EVM state for precompile handling does not leak between blocks | Block 1: Alice calls RIPEMD-160 (0x03) with zero value (RIPEMD-160 must be pre-funded). Block 2: Bob's transaction triggers an exception (stack underflow). | BAL for Block 1 **MUST** include RIPEMD-160. BAL for Block 2 **MUST NOT** include RIPEMD-160 (never accessed in Block 2). Internal state from Parity Touch Bug (EIP-161) handling must be reset between blocks. | ✅ Completed | -| `test_bal_self_destruct_oog` | Ensure BAL correctly tracks SELFDESTRUCT beneficiary based on gas boundaries | Alice calls `Caller` contract which CALLs `SelfDestructContract` with precisely controlled gas. `SelfDestructContract` attempts SELFDESTRUCT to new account `Beneficiary`. Static gas = G_VERY_LOW + G_SELF_DESTRUCT + G_COLD_ACCOUNT_ACCESS. Parameterized: (1) OOG before state access (gas = static - 1), (2) OOG after state access (gas = static, but insufficient for G_NEW_ACCOUNT). | For OOG before state access: BAL **MUST NOT** include `Beneficiary` (no state access occurred). For OOG after state access: BAL **MUST** include `Beneficiary` with empty changes (state was accessed before G_NEW_ACCOUNT check failed). Both cases: Alice with `nonce_changes`, `Caller` and `SelfDestructContract` with empty changes. Contract balance unchanged. | ✅ Completed | From a0c874785188b7c5c0e2ba646ab54bb02f5c1314 Mon Sep 17 00:00:00 2001 From: felipe Date: Mon, 8 Dec 2025 14:40:18 -0700 Subject: [PATCH 47/51] feat(test-tests): BAL test for nested storage write reset same tx (#1854) --- .../test_block_access_lists.py | 92 +++++++++++++++++++ .../test_cases.md | 1 + 2 files changed, 93 insertions(+) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py index b17935e1e3..b9dbcf6244 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists.py @@ -1935,6 +1935,98 @@ def test_bal_multiple_storage_writes_same_slot( ) +@pytest.mark.parametrize( + "intermediate_values", + [ + pytest.param([2], id="depth_1"), + pytest.param([2, 3], id="depth_2"), + pytest.param([2, 3, 4], id="depth_3"), + ], +) +def test_bal_nested_delegatecall_storage_writes_net_zero( + pre: Alloc, + blockchain_test: BlockchainTestFiller, + intermediate_values: list, +) -> None: + """ + Test BAL correctly handles nested DELEGATECALL frames where intermediate + frames write different values but the deepest frame reverts to original. + + Each nesting level writes a different intermediate value, and the deepest + frame writes back the original value, resulting in net-zero change. + + Example for depth=2 (intermediate_values=[2, 3]): + - Pre-state: slot 0 = 1 + - Root frame writes: slot 0 = 2 + - Child frame writes: slot 0 = 3 + - Grandchild frame writes: slot 0 = 1 (back to original) + - Expected: No storage_changes (net-zero overall) + """ + alice = pre.fund_eoa() + starting_value = 1 + + # deepest contract writes back to starting_value + deepest_code = Op.SSTORE(0, starting_value) + Op.STOP + next_contract = pre.deploy_contract(code=deepest_code) + delegate_contracts = [next_contract] + + # Build intermediate contracts (in reverse order) that write then + # DELEGATECALL. Skip the first value since that's for the root contract + for value in reversed(intermediate_values[1:]): + code = ( + Op.SSTORE(0, value) + + Op.DELEGATECALL(100_000, next_contract, 0, 0, 0, 0) + + Op.STOP + ) + next_contract = pre.deploy_contract(code=code) + delegate_contracts.append(next_contract) + + # root_contract writes first intermediate value, then DELEGATECALLs + root_contract = pre.deploy_contract( + code=( + Op.SSTORE(0, intermediate_values[0]) + + Op.DELEGATECALL(100_000, next_contract, 0, 0, 0, 0) + + Op.STOP + ), + storage={0: starting_value}, + ) + + tx = Transaction( + sender=alice, + to=root_contract, + gas_limit=500_000, + ) + + account_expectations = { + alice: BalAccountExpectation( + nonce_changes=[BalNonceChange(tx_index=1, post_nonce=1)], + ), + root_contract: BalAccountExpectation( + storage_reads=[0], + storage_changes=[], # validate no changes + ), + } + # All delegate contracts accessed but no changes + for contract in delegate_contracts: + account_expectations[contract] = BalAccountExpectation.empty() + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=BlockAccessListExpectation( + account_expectations=account_expectations + ), + ) + ], + post={ + alice: Account(nonce=1), + root_contract: Account(storage={0: starting_value}), + }, + ) + + def test_bal_create_transaction_empty_code( pre: Alloc, blockchain_test: BlockchainTestFiller, diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md index 07d184d957..2a318f5b63 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_cases.md @@ -54,6 +54,7 @@ | `test_bal_oog_7702_delegated_warm_cold` | Ensure BAL handles OOG during EIP-7702 delegated account loading when first account is warm, second is cold | Alice calls warm delegated account Bob (7702) which delegates to cold `TargetContract` with insufficient gas for second cold load | BAL **MUST** include Bob in `account_changes` (warm load succeeds) but **MUST NOT** include `TargetContract` (cold load fails due to OOG) | 🟡 Planned | | `test_bal_multiple_balance_changes_same_account` | Ensure BAL tracks multiple balance changes to same account across transactions | Alice funds Bob (starts at 0) in tx0 with exact amount needed. Bob spends everything in tx1 to Charlie. Bob's balance: 0 → funding_amount → 0 | BAL **MUST** include Bob with two `balance_changes`: one at txIndex=1 (receives funds) and one at txIndex=2 (balance returns to 0). This tests balance tracking across two transactions. | ✅ Completed | | `test_bal_multiple_storage_writes_same_slot` | Ensure BAL tracks multiple writes to same storage slot across transactions | Alice calls contract 3 times in same block. Contract increments slot 1 on each call: 0 → 1 → 2 → 3 | BAL **MUST** include contract with slot 1 having three `slot_changes`: txIndex=1 (value 1), txIndex=2 (value 2), txIndex=3 (value 3). Each transaction's write must be recorded separately. | ✅ Completed | +| `test_bal_nested_delegatecall_storage_writes_net_zero` | Ensure BAL correctly filters net-zero storage changes across nested DELEGATECALL frames | Parametrized by nesting depth (1-3). Root contract has slot 0 = 1. Each frame writes a different intermediate value via DELEGATECALL chain, deepest frame writes back to original value (1). Example depth=2: 1 → 2 → 3 → 1 | BAL **MUST** include root contract with `storage_reads` for slot 0 but **MUST NOT** include `storage_changes` (net-zero). All delegate contracts **MUST** have empty changes. Tests that frame merging correctly removes parent's intermediate writes when child reverts to pre-tx value. | ✅ Completed | | `test_bal_cross_tx_storage_revert_to_zero` | Ensure BAL captures storage changes when tx2 reverts slot back to original value (blobhash regression test) | Alice sends tx1 writing slot 0=0xABCD (from 0x0), then tx2 writing slot 0=0x0 (back to original) | BAL **MUST** include contract with slot 0 having two `slot_changes`: txIndex=1 (0xABCD) and txIndex=2 (0x0). Cross-transaction net-zero **MUST NOT** be filtered. | ✅ Completed | | `test_bal_create_contract_init_revert` | Ensure BAL correctly handles CREATE when parent call reverts | Caller calls factory, factory executes CREATE (succeeds), then factory REVERTs rolling back the CREATE | BAL **MUST** include Alice with `nonce_changes`. Caller and factory with no changes (reverted). Created contract address appears in BAL but **MUST NOT** have `nonce_changes` or `code_changes` (CREATE was rolled back). Contract address **MUST NOT** exist in post-state. | ✅ Completed | | `test_bal_create_oog_code_deposit` | Ensure BAL correctly handles CREATE OOG during code deposit | Alice calls factory contract that executes CREATE with init code returning 10,000 bytes. Transaction has insufficient gas for code deposit. Factory nonce increments, CREATE returns 0 and stores in slot 1. | BAL **MUST** include Alice with `nonce_changes`. Factory with `nonce_changes` (incremented by CREATE) and `storage_changes` (slot 1 = 0). Contract address with empty changes (read during collision check). **MUST NOT** include nonce or code changes for contract address (rolled back on OOG). Contract address **MUST NOT** exist in post-state. | ✅ Completed | From 6044b67de4b92da5520ce7ecc264cfabaadc8587 Mon Sep 17 00:00:00 2001 From: fselmo Date: Mon, 8 Dec 2025 18:36:18 -0700 Subject: [PATCH 48/51] fix(spec-specs): Fix issues after rebasing with forks/osaka --- .../evm_tools/loaders/fork_loader.py | 9 +++++++++ src/ethereum_spec_tools/evm_tools/t8n/__init__.py | 13 ++++--------- src/ethereum_spec_tools/evm_tools/t8n/env.py | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py index 8f4a9db3b2..64e920d266 100644 --- a/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py +++ b/src/ethereum_spec_tools/evm_tools/loaders/fork_loader.py @@ -132,6 +132,15 @@ def compute_block_access_list_hash(self) -> Any: "block_access_lists" ).compute_block_access_list_hash + @property + def has_block_access_list_hash(self) -> bool: + """Check if the fork has a `block_access_list_hash` function.""" + try: + module = self._module("block_access_lists") + except ModuleNotFoundError: + return False + return hasattr(module, "compute_block_access_list_hash") + @property def signing_hash_2930(self) -> Any: """signing_hash_2930 function of the fork.""" diff --git a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py index c029091467..4988ef25bc 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/__init__.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/__init__.py @@ -16,9 +16,8 @@ from ethereum import trace from ethereum.exceptions import EthereumException, InvalidBlock from ethereum.fork_criteria import ByBlockNumber, ByTimestamp, Unscheduled -from ethereum_spec_tools.forks import TemporaryHardfork from ethereum.forks.amsterdam.state_tracker import StateChanges -from ethereum_spec_tools.forks import Hardfork +from ethereum_spec_tools.forks import Hardfork, TemporaryHardfork from ..loaders.fixture_loader import Load from ..utils import ( @@ -310,7 +309,7 @@ def block_environment(self) -> Any: ) kw_arguments["excess_blob_gas"] = self.env.excess_blob_gas - if self.fork.is_after_fork("amsterdam"): + if self.fork.has_block_access_list_hash: kw_arguments["state_changes"] = StateChanges() return block_environment(**kw_arguments) @@ -377,10 +376,6 @@ def run_state_test(self) -> Any: self.result.rejected = self.txs.rejected_txs def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: - if self.fork.is_after_fork("amsterdam"): - self.fork.set_block_access_index( - block_env.state.change_tracker, Uint(0) - ) if self.fork.has_compute_requests_hash: self.fork.process_unchecked_system_transaction( block_env=block_env, @@ -417,7 +412,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: ) # Post-execution operations use index N+1 - if self.fork.is_after_fork("amsterdam"): + if self.fork.has_block_access_list_hash: from ethereum.forks.amsterdam.state_tracker import ( increment_block_access_index, ) @@ -440,7 +435,7 @@ def _run_blockchain_test(self, block_env: Any, block_output: Any) -> None: if self.fork.has_compute_requests_hash: self.fork.process_general_purpose_requests(block_env, block_output) - if self.fork.is_after_fork("amsterdam"): + if self.fork.has_block_access_list_hash: # Build block access list from block_env.state_changes block_output.block_access_list = self.fork.build_block_access_list( block_env.state_changes diff --git a/src/ethereum_spec_tools/evm_tools/t8n/env.py b/src/ethereum_spec_tools/evm_tools/t8n/env.py index 8cadd58c9f..be719ba7af 100644 --- a/src/ethereum_spec_tools/evm_tools/t8n/env.py +++ b/src/ethereum_spec_tools/evm_tools/t8n/env.py @@ -145,7 +145,7 @@ def read_excess_blob_gas(self, data: Any, t8n: "T8N") -> None: if t8n.fork.has_compute_requests_hash: arguments["requests_hash"] = Hash32(b"\0" * 32) - if t8n.fork.is_after_fork("amsterdam"): + if t8n.fork.has_block_access_list_hash: arguments["block_access_list_hash"] = Hash32(b"\0" * 32) parent_header = t8n.fork.Header(**arguments) From dd3a353650133bc22e5497507909132cbb313dd0 Mon Sep 17 00:00:00 2001 From: Felipe Selmo Date: Tue, 9 Dec 2025 16:07:17 +0000 Subject: [PATCH 49/51] fix(spec-specs): post-exec net-zero filtering post specs refactor --- src/ethereum/forks/amsterdam/fork.py | 3 ++ .../test_block_access_lists_cross_index.py | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index 697086e2b4..ce16f2b1b2 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -679,6 +679,9 @@ def process_system_transaction( system_tx_output = process_message_call(system_tx_message) + # EIP-7928: Filter net-zero changes before committing to block frame. + filter_net_zero_frame_changes(tx_env.state_changes, block_env.state) + # Commit system transaction changes to block frame # System transactions always succeed (or block is invalid) commit_transaction_frame(tx_env.state_changes) diff --git a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py index 920b8bc344..72ac91c923 100644 --- a/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py +++ b/tests/amsterdam/eip7928_block_level_access_lists/test_block_access_lists_cross_index.py @@ -258,3 +258,57 @@ def test_bal_noop_write_filtering( test_address: Account(storage={2: 42, 3: 100, 4: 200}), }, ) + + +def test_bal_system_contract_noop_filtering( + pre: Alloc, + blockchain_test: BlockchainTestFiller, +) -> None: + """ + Test that system contract post-execution calls filter net-zero + storage writes. + + When no transaction interacts with withdrawal/consolidation contracts + during a block, the post-execution system calls read storage slots + 0-3 but don't modify them. These should appear as storage READS, + not storage CHANGES. + """ + sender = pre.fund_eoa() + receiver = pre.fund_eoa(amount=0) + + # simple transfer that doesn't interact with system contracts + tx = Transaction( + sender=sender, + to=receiver, + value=100, + gas_limit=21_000, + ) + + # withdrawal and consolidation contracts should NOT have any storage + # changes since they weren't modified - only reads occurred during + # post-execution system calls + expected_block_access_list = BlockAccessListExpectation( + account_expectations={ + WITHDRAWAL_REQUEST_ADDRESS: BalAccountExpectation( + storage_changes=[], + storage_reads=[0x00, 0x01, 0x02, 0x03], + ), + CONSOLIDATION_REQUEST_ADDRESS: BalAccountExpectation( + storage_changes=[], + storage_reads=[0x00, 0x01, 0x02, 0x03], + ), + } + ) + + blockchain_test( + pre=pre, + blocks=[ + Block( + txs=[tx], + expected_block_access_list=expected_block_access_list, + ) + ], + post={ + receiver: Account(balance=100), + }, + ) From 3a85f3db8fd88c22f8889db6b03e2997a788ed16 Mon Sep 17 00:00:00 2001 From: Felipe Selmo Date: Tue, 9 Dec 2025 16:56:01 +0000 Subject: [PATCH 50/51] refactor(spec-specs): Move net-zero filtering inside commit tx frame --- src/ethereum/forks/amsterdam/fork.py | 11 +++-------- src/ethereum/forks/amsterdam/state_tracker.py | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/ethereum/forks/amsterdam/fork.py b/src/ethereum/forks/amsterdam/fork.py index ce16f2b1b2..7da3ed03ce 100644 --- a/src/ethereum/forks/amsterdam/fork.py +++ b/src/ethereum/forks/amsterdam/fork.py @@ -679,12 +679,9 @@ def process_system_transaction( system_tx_output = process_message_call(system_tx_message) - # EIP-7928: Filter net-zero changes before committing to block frame. - filter_net_zero_frame_changes(tx_env.state_changes, block_env.state) - # Commit system transaction changes to block frame # System transactions always succeed (or block is invalid) - commit_transaction_frame(tx_env.state_changes) + commit_transaction_frame(tx_env.state_changes, block_env.state) return system_tx_output @@ -1095,11 +1092,9 @@ def process_transaction( for address in tx_output.accounts_to_delete: destroy_account(block_env.state, address) - # EIP-7928: Filter net-zero changes before committing to block frame. + # EIP-7928: Commit transaction frame (includes net-zero filtering). # Must happen AFTER destroy_account so filtering sees correct state. - filter_net_zero_frame_changes(tx_env.state_changes, block_env.state) - - commit_transaction_frame(tx_env.state_changes) + commit_transaction_frame(tx_env.state_changes, block_env.state) # EIP-7928: Track in-transaction self-destruct normalization AFTER merge # Convert storage writes to reads and remove nonce/code changes diff --git a/src/ethereum/forks/amsterdam/state_tracker.py b/src/ethereum/forks/amsterdam/state_tracker.py index 19a929d0dd..05461ea89b 100644 --- a/src/ethereum/forks/amsterdam/state_tracker.py +++ b/src/ethereum/forks/amsterdam/state_tracker.py @@ -436,22 +436,30 @@ def merge_on_failure(child_frame: StateChanges) -> None: # merged on failure - they are discarded -def commit_transaction_frame(tx_frame: StateChanges) -> None: +def commit_transaction_frame( + tx_frame: StateChanges, + state: "State", +) -> None: """ Commit transaction frame to block frame. - Unlike merge_on_success(), this merges ALL changes without net-zero - filtering (each tx's changes recorded at their respective index). + Filters net-zero changes before merging to ensure only actual state + modifications are recorded in the block access list. Parameters ---------- tx_frame : The transaction frame to commit. + state : + The current state (used for net-zero filtering). """ assert tx_frame.parent is not None block_frame = tx_frame.parent + # Filter net-zero changes before committing + filter_net_zero_frame_changes(tx_frame, state) + # Merge address accesses block_frame.touched_addresses.update(tx_frame.touched_addresses) @@ -468,7 +476,7 @@ def commit_transaction_frame(tx_frame: StateChanges) -> None: for addr, idx, nonce in tx_frame.nonce_changes: block_frame.nonce_changes.add((addr, idx, nonce)) - # Merge code changes (net-zero filtering done in normalize_transaction) + # Merge code changes for (addr, idx), final_code in tx_frame.code_changes.items(): block_frame.code_changes[(addr, idx)] = final_code From d0726cc9c8019190175e36ee111e3beacd3c9170 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 12 Dec 2025 14:49:40 +0100 Subject: [PATCH 51/51] enable BPO1 and BPO2 for Amsterdam --- .../plugins/consume/simulators/helpers/ruleset.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py index 15ab56bc9c..2d28cbb749 100644 --- a/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py +++ b/packages/testing/src/execution_testing/cli/pytest_commands/plugins/consume/simulators/helpers/ruleset.py @@ -494,10 +494,8 @@ def get_blob_schedule_entries(fork: Fork) -> Dict[str, int]: "HIVE_CANCUN_TIMESTAMP": 0, "HIVE_PRAGUE_TIMESTAMP": 0, "HIVE_OSAKA_TIMESTAMP": 0, - # TODO: While we are still reworking BPO interaction with T8N, - # turn off BPO timestamps for now. - # "HIVE_BPO1_TIMESTAMP": 0, - # "HIVE_BPO2_TIMESTAMP": 0, + "HIVE_BPO1_TIMESTAMP": 0, + "HIVE_BPO2_TIMESTAMP": 0, "HIVE_AMSTERDAM_TIMESTAMP": 0, }, }