diff --git a/.github/config/hive/amsterdam.yaml b/.github/config/hive/amsterdam.yaml index 1e48471197b..424ff42d4d8 100644 --- a/.github/config/hive/amsterdam.yaml +++ b/.github/config/hive/amsterdam.yaml @@ -1,4 +1,4 @@ # Amsterdam (BAL) hive test configuration -# Pinned from ethereum/execution-specs devnets/bal/3 @ 2026-04-14 -fixtures: https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.6.1/fixtures_bal.tar.gz -eels_commit: 5c6e20abf3586f52d9e58393203ca07f2d0151fe +# Pinned to snobal-devnet-6@v1.1.0 +fixtures: https://github.com/ethereum/execution-spec-tests/releases/download/snobal-devnet-6%40v1.1.0/fixtures_snobal-devnet-6.tar.gz +eels_commit: e87390b69ca5113f8f99db81dae7e4d6a8420342 diff --git a/.github/scripts/check-hive-results.sh b/.github/scripts/check-hive-results.sh index 065f56362ef..afe3566eb2b 100755 --- a/.github/scripts/check-hive-results.sh +++ b/.github/scripts/check-hive-results.sh @@ -57,18 +57,64 @@ failed_logs_root="${results_dir}/failed_logs" rm -rf "${failed_logs_root}" mkdir -p "${failed_logs_root}" -# Known-flaky tests to ignore (substring match against test case name). -# These are hive framework issues, not ethrex bugs. -KNOWN_FLAKY_TESTS=( +# Tests excluded from the failure count (substring match against test case +# name). Two categories live here: +# 1. Genuinely flaky hive-framework tests, not ethrex bugs. +# 2. bal-devnet-6 fixture-vs-impl mismatch routed through hive's +# consume-engine simulator (mirrors the blockchain-runner skip list +# in tooling/ef_tests/blockchain/tests/all.rs SKIPPED_BASE). +KNOWN_EXCLUDED_TESTS=( + # (1) Flaky โ€” hive-framework instability. "Invalid Missing Ancestor Syncing ReOrg, Timestamp, EmptyTxs=False, CanonicalReOrg=False, Invalid P8" "Invalid Missing Ancestor Syncing ReOrg, Timestamp, EmptyTxs=False, CanonicalReOrg=True, Invalid P8" "Invalid Missing Ancestor Syncing ReOrg, Transaction Value, EmptyTxs=False, CanonicalReOrg=False, Invalid P9" + # (2) bal-devnet-6 known-failing fixtures (Amsterdam fork) routed through + # hive's `eels/consume-engine` simulator. Same root cause as the + # blockchain-runner SKIPPED_BASE: snobal-devnet-6 fixtures expect + # bal-devnet-6 spec semantics, but our impl runs ahead due to + # bal-devnet-7-prep `set_delegation` SELFDESTRUCT-style refund + # subtraction. Anchored on `[fork_Amsterdam` so any Prague/Osaka + # variants of the same EELS test functions still run. Re-enable once + # fixtures bump to snobal-devnet-7 or the bal-devnet-7-prep subtraction + # is reverted. + "test_auth_refund_block_gas_accounting[fork_Amsterdam" + "test_auth_refund_bypasses_one_fifth_cap[fork_Amsterdam" + "test_auth_state_gas_scales_with_cpsb[fork_Amsterdam" + "test_auth_with_calldata_and_access_list[fork_Amsterdam" + "test_auth_with_multiple_sstores[fork_Amsterdam" + "test_authorization_exact_state_gas_boundary[fork_Amsterdam" + "test_authorization_to_precompile_address[fork_Amsterdam" + "test_authorization_with_sstore[fork_Amsterdam" + "test_bal_7702_delegation_clear[fork_Amsterdam" + "test_bal_7702_delegation_create[fork_Amsterdam" + "test_bal_7702_delegation_update[fork_Amsterdam" + "test_bal_7702_double_auth_reset[fork_Amsterdam" + "test_bal_7702_double_auth_swap[fork_Amsterdam" + "test_bal_7702_null_address_delegation_no_code_change[fork_Amsterdam" + "test_bal_all_transaction_types[fork_Amsterdam" + "test_bal_selfdestruct_to_7702_delegation[fork_Amsterdam" + "test_bal_withdrawal_to_7702_delegation[fork_Amsterdam" + "test_duplicate_signer_authorizations[fork_Amsterdam" + "test_existing_account_auth_header_gas_used_uses_worst_case[fork_Amsterdam" + "test_existing_account_refund[fork_Amsterdam" + "test_existing_account_refund_enables_sstore[fork_Amsterdam" + "test_existing_auth_with_reverted_execution_preserves_intrinsic[fork_Amsterdam" + "test_many_authorizations_state_gas[fork_Amsterdam" + "test_mixed_auths_header_gas_used_uses_worst_case[fork_Amsterdam" + "test_mixed_new_and_existing_auths[fork_Amsterdam" + "test_mixed_valid_and_invalid_auths[fork_Amsterdam" + "test_multi_tx_block_auth_refund_and_sstore[fork_Amsterdam" + "test_multiple_refund_types_in_one_tx[fork_Amsterdam" + "test_simple_gas_accounting[fork_Amsterdam" + "test_sstore_state_gas_all_tx_types[fork_Amsterdam" + "test_transfer_with_all_tx_types[fork_Amsterdam" + "test_varying_calldata_costs[fork_Amsterdam" ) -# Build a jq filter that excludes known-flaky tests. -flaky_filter='true' -for pattern in "${KNOWN_FLAKY_TESTS[@]}"; do - flaky_filter="${flaky_filter} and (.name | contains(\"${pattern}\") | not)" +# Build a jq filter that excludes the known-excluded tests. +exclude_filter='true' +for pattern in "${KNOWN_EXCLUDED_TESTS[@]}"; do + exclude_filter="${exclude_filter} and (.name | contains(\"${pattern}\") | not)" done for json_file in "${json_files[@]}"; do @@ -77,11 +123,11 @@ for json_file in "${json_files[@]}"; do fi suite_name="$(jq -r '.name // empty' "${json_file}")" - failed_cases="$(jq '[.testCases[]? | select(.summaryResult.pass != true) | select('"${flaky_filter}"')] | length' "${json_file}")" + failed_cases="$(jq '[.testCases[]? | select(.summaryResult.pass != true) | select('"${exclude_filter}"')] | length' "${json_file}")" - skipped_flaky="$(jq '[.testCases[]? | select(.summaryResult.pass != true) | select(('"${flaky_filter}"') | not)] | length' "${json_file}")" - if [ "${skipped_flaky}" -gt 0 ]; then - echo "Ignoring ${skipped_flaky} known-flaky test(s) in ${suite_name:-$(basename "${json_file}")}" + skipped_excluded="$(jq '[.testCases[]? | select(.summaryResult.pass != true) | select(('"${exclude_filter}"') | not)] | length' "${json_file}")" + if [ "${skipped_excluded}" -gt 0 ]; then + echo "Ignoring ${skipped_excluded} known-excluded test(s) in ${suite_name:-$(basename "${json_file}")}" fi if [ "${failed_cases}" -gt 0 ]; then @@ -90,7 +136,7 @@ for json_file in "${json_files[@]}"; do jq -r ' .testCases[]? | select(.summaryResult.pass != true) - | select('"${flaky_filter}"') + | select('"${exclude_filter}"') | . as $case | ($case.summaryResult // {}) as $summary | ($summary.message // $summary.reason // $summary.error // "") as $message @@ -144,9 +190,9 @@ for json_file in "${json_files[@]}"; do [ .simLog?, .testDetailsLog?, - (.testCases[]? | select(.summaryResult.pass != true) | select('"${flaky_filter}"') | .clientInfo? | to_entries? // [] | map(.value.logFile? // empty) | .[]), - (.testCases[]? | select(.summaryResult.pass != true) | select('"${flaky_filter}"') | .summaryResult.logFile?), - (.testCases[]? | select(.summaryResult.pass != true) | select('"${flaky_filter}"') | .logFile?) + (.testCases[]? | select(.summaryResult.pass != true) | select('"${exclude_filter}"') | .clientInfo? | to_entries? // [] | map(.value.logFile? // empty) | .[]), + (.testCases[]? | select(.summaryResult.pass != true) | select('"${exclude_filter}"') | .summaryResult.logFile?), + (.testCases[]? | select(.summaryResult.pass != true) | select('"${exclude_filter}"') | .logFile?) ] | map(select(. != null and . != "")) | unique @@ -216,7 +262,7 @@ for json_file in "${json_files[@]}"; do .testCases | to_entries[] | select(.value.summaryResult.pass != true) - | select(.value | '"${flaky_filter}"') + | select(.value | '"${exclude_filter}"') | . as $case_entry | ($case_entry.value.clientInfo? // {}) | to_entries[] | [ diff --git a/.github/workflows/pr-main_l1.yaml b/.github/workflows/pr-main_l1.yaml index 25b80a3e457..7bfaf8c2946 100644 --- a/.github/workflows/pr-main_l1.yaml +++ b/.github/workflows/pr-main_l1.yaml @@ -126,6 +126,79 @@ jobs: run: | make -C tooling/ef_tests/blockchain test + - name: Append Known Issues to job summary + if: ${{ always() && github.event_name != 'merge_group' && hashFiles('docs/known_issues.md') != '' }} + shell: bash + run: | + { + echo "## Known Issues (intentionally skipped)" + echo "" + echo "_Source: [\`docs/known_issues.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/docs/known_issues.md)_" + echo "" + cat docs/known_issues.md + } >> "$GITHUB_STEP_SUMMARY" + + known-issues-comment: + name: Post Known Issues sticky comment + runs-on: ubuntu-latest + # Only on PRs from the same repo (forks lack write perms for comments). + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }} + permissions: + contents: read + pull-requests: write + issues: write + steps: + - name: Checkout sources + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + sparse-checkout: | + docs/known_issues.md + sparse-checkout-cone-mode: false + + - name: Check if known_issues.md exists + id: check + shell: bash + run: | + if [ -s docs/known_issues.md ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build comment body + if: steps.check.outputs.exists == 'true' + shell: bash + run: | + { + echo "" + echo "## :warning: Known Issues โ€” intentionally skipped tests" + echo "" + echo "_Source: [\`docs/known_issues.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/docs/known_issues.md)_" + echo "" + cat docs/known_issues.md + } > known_issues_comment.md + + - name: Find existing comment + if: steps.check.outputs.exists == 'true' + continue-on-error: true + uses: peter-evans/find-comment@v4 + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "" + + - name: Create or update comment + if: steps.check.outputs.exists == 'true' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.pull_request.number }} + body-path: known_issues_comment.md + edit-mode: replace + docker_build: name: Build Docker runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 1245085af21..92afee8ba3f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.pdb tooling/ef_tests/blockchain/vectors +tooling/ef_tests/blockchain/vectors_zkevm tooling/ef_tests/state/vectors diff --git a/Makefile b/Makefile index 059c814c4a1..abe31a07888 100644 --- a/Makefile +++ b/Makefile @@ -148,8 +148,8 @@ run-hive-eels-rlp: ## Run hive EELS RLP tests run-hive-eels-blobs: ## Run hive EELS Blobs tests $(MAKE) run-hive-eels EELS_SIM=ethereum/eels/execute-blobs -AMSTERDAM_FIXTURES_URL ?= https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.6.1/fixtures_bal.tar.gz -AMSTERDAM_FIXTURES_BRANCH ?= devnets/bal/3 +AMSTERDAM_FIXTURES_URL ?= $(shell cat tooling/ef_tests/blockchain/.fixtures_url_amsterdam) +AMSTERDAM_FIXTURES_BRANCH ?= devnets/snobal/6 run-hive-eels-amsterdam: build-image setup-hive ## ๐Ÿงช Run hive EELS Amsterdam Engine tests - cd hive && ./hive --client-file $(HIVE_CLIENT_FILE) --client ethrex --sim ethereum/eels/consume-engine --sim.limit ".*fork_Amsterdam.*" --sim.parallelism $(SIM_PARALLELISM) --sim.loglevel $(SIM_LOG_LEVEL) --sim.buildarg fixtures=$(AMSTERDAM_FIXTURES_URL) --sim.buildarg branch=$(AMSTERDAM_FIXTURES_BRANCH) diff --git a/crates/blockchain/constants.rs b/crates/blockchain/constants.rs index 733cd7f06be..62821995d7b 100644 --- a/crates/blockchain/constants.rs +++ b/crates/blockchain/constants.rs @@ -52,3 +52,10 @@ pub const MIN_GAS_LIMIT: u64 = 5000; // === EIP-7825 constants === // https://eips.ethereum.org/EIPS/eip-7825 pub const POST_OSAKA_GAS_LIMIT_CAP: u64 = 16777216; + +// === EIP-7981 / EIP-7976 constants (Amsterdam+) === +// access_list_bytes * STANDARD_TOKEN_COST(4) * TOTAL_COST_FLOOR_PER_TOKEN(16) = access_list_bytes * 64 +// Per address entry: 20 bytes * 64 = 1280 +pub const TX_ACCESS_LIST_ADDRESS_DATA_GAS_AMSTERDAM: u64 = 1280; +// Per storage key entry: 32 bytes * 64 = 2048 +pub const TX_ACCESS_LIST_STORAGE_KEY_DATA_GAS_AMSTERDAM: u64 = 2048; diff --git a/crates/blockchain/error.rs b/crates/blockchain/error.rs index 39436472dd4..720b518045f 100644 --- a/crates/blockchain/error.rs +++ b/crates/blockchain/error.rs @@ -152,6 +152,8 @@ pub enum InvalidForkChoice { InvalidAncestor(BlockHash), #[error("Cannot find link between Head and the canonical chain")] UnlinkedHead, + #[error("Reorg depth {reorg_depth} exceeds the client's limit of {limit}")] + TooDeepReorg { reorg_depth: u64, limit: u64 }, // TODO(#5564): handle arbitrary reorgs #[error("State root of the new head is not reachable from the database")] diff --git a/crates/blockchain/fork_choice.rs b/crates/blockchain/fork_choice.rs index fa68bee8bf6..67fcb1e7e6f 100644 --- a/crates/blockchain/fork_choice.rs +++ b/crates/blockchain/fork_choice.rs @@ -11,6 +11,22 @@ use crate::{ is_canonical, }; +/// Maximum number of canonical blocks ethrex can revert in a single forkchoice update. +/// +/// This is an implementation cap, not a spec policy. ethrex's state-history retention +/// keeps the last ~128 blocks of state diffs, so reorgs deeper than this cannot be +/// undone regardless of finalization status โ€” the data simply isn't there. +/// +/// The spec (execution-apis PR 786, "engine: Restrict no-reorg to the prefix of known +/// finalized") only forbids reorging past the finalized prefix. The finalized check is +/// applied first; this cap is a secondary guard for the implementation limit. +/// +/// Reference values across ELs (devnet branches, 2026-04-30): +/// - besu (main): 90_000 โ€” effectively unlimited +/// - erigon (glamsterdam-devnet-0): 96, env-configurable via `MAX_REORG_DEPTH` +/// - geth / nethermind / reth: no engine-API rejection; trust the CL's fork choice +pub const REORG_DEPTH_LIMIT: u64 = 128; + /// Applies new fork choice data to the current blockchain. It performs validity checks: /// - The finalized, safe and head hashes must correspond to already saved blocks. /// - The saved blocks should be in the correct order (finalized <= safe <= head). @@ -57,9 +73,15 @@ pub async fn apply_fork_choice( }; let latest = store.get_latest_block_number().await?; + let head_is_canonical = is_canonical(store, head.number, head_hash).await?; - // If the head block is an already present head ancestor, skip the update. - if is_canonical(store, head.number, head_hash).await? && head.number < latest { + // execution-apis PR 786: the no-reorg skip is only allowed when there is a known + // finalized block and the head references a VALID ancestor of it. Skipping for + // unfinalized canonical ancestors is no longer permitted - those must trigger a reorg. + if let Some(stored_finalized) = store.get_finalized_block_number().await? + && head.number <= stored_finalized + && head_is_canonical + { return Err(InvalidForkChoice::NewHeadAlreadyCanonical); } @@ -98,6 +120,35 @@ pub async fn apply_fork_choice( )); } + // execution-apis PR 786 point 6: -38006 TooDeepReorg is returned when the reorg + // depth exceeds the limitation specific to the client software. ethrex's limit + // is its state-history retention: we keep the last REORG_DEPTH_LIMIT blocks of + // state diffs, so reorgs deeper than that cannot be unwound. We do not reject + // reorgs that would cross the finalized prefix โ€” the spec's only requirement on + // finalized is point 2 (skip-when-ancestor-of-finalized, handled above) and + // point 5 (-38002 for disconnected safe/finalized). The CL is authoritative on + // fork choice and an EL must honor what the CL sends if it physically can. + // + // The shared canonical ancestor is `head` itself when head is canonical (the + // FCU truncates the canonical chain), or one below the lowest sidechain block + // in `new_canonical_blocks` otherwise. + let canonical_link_height = if head_is_canonical { + head.number + } else { + new_canonical_blocks + .last() + .map(|(n, _)| *n) + .unwrap_or(head.number) + .saturating_sub(1) + }; + let reorg_depth = latest.saturating_sub(canonical_link_height); + if reorg_depth > REORG_DEPTH_LIMIT { + return Err(InvalidForkChoice::TooDeepReorg { + reorg_depth, + limit: REORG_DEPTH_LIMIT, + }); + } + let Some(link_header) = store.get_block_header_by_hash(link_block_hash)? else { // Probably unreachable, but we return this error just in case. error!("Link block not found although it was just retrieved from the DB"); diff --git a/crates/blockchain/mempool.rs b/crates/blockchain/mempool.rs index d8b67c8f9b3..94f7c1e21a3 100644 --- a/crates/blockchain/mempool.rs +++ b/crates/blockchain/mempool.rs @@ -21,6 +21,7 @@ use ethrex_common::{ }, }; use ethrex_storage::error::StoreError; +use ethrex_vm::{intrinsic_gas_dimensions, intrinsic_gas_floor}; use tracing::warn; #[derive(Debug, Default)] @@ -512,6 +513,30 @@ pub fn transaction_intrinsic_gas( header: &BlockHeader, config: &ChainConfig, ) -> Result { + // Amsterdam (EIP-8037): the VM splits intrinsic into (regular, state) and uses + // `REGULAR_GAS_CREATE = 9000` + `STATE_BYTES_PER_NEW_ACCOUNT * cpsb` for CREATE + // instead of the legacy `TX_CREATE_GAS_COST = 53000`. Mempool admission must + // match VM charge or we spuriously reject (or admit) transactions. + // + // The VM enforces `gas_limit >= max(intrinsic_regular + intrinsic_state, + // floor)` via two separate checks in `validate_gas_allowance` + + // `validate_min_gas_limit`. Apply the same max here so we don't admit + // txs whose calldata floor exceeds the weighted intrinsic โ€” those would + // pass mempool and then fail at block inclusion, polluting the pool. + if config.is_amsterdam_activated(header.timestamp) { + let fork = config.fork(header.timestamp); + let (regular, state) = intrinsic_gas_dimensions(tx, fork, header.gas_limit) + .map_err(|_| MempoolError::TxGasOverflowError)?; + let intrinsic = regular + .checked_add(state) + .ok_or(MempoolError::TxGasOverflowError)?; + let floor = intrinsic_gas_floor(tx, fork).map_err(|_| MempoolError::TxGasOverflowError)?; + // Block-level gas = max(regular_dim, state_dim); regular_dim itself is + // `max(tx_regular, calldata_floor)` per EIP-7778. Use the same max so + // admission mirrors the VM's effective minimum. + return Ok(intrinsic.max(floor)); + } + let is_contract_creation = tx.is_contract_creation(); let mut gas = if is_contract_creation { diff --git a/crates/blockchain/payload.rs b/crates/blockchain/payload.rs index 1748a07cc2a..0e2a36e2203 100644 --- a/crates/blockchain/payload.rs +++ b/crates/blockchain/payload.rs @@ -15,7 +15,7 @@ use ethrex_common::{ }, types::{ AccountUpdate, BlobsBundle, Block, BlockBody, BlockHash, BlockHeader, BlockNumber, - ChainConfig, MempoolTransaction, Receipt, Transaction, TxKind, TxType, Withdrawal, + ChainConfig, Fork, MempoolTransaction, Receipt, Transaction, TxKind, TxType, Withdrawal, block_access_list::BlockAccessList, bloom_from_logs, calc_excess_blob_gas, calculate_base_fee_per_blob_gas, calculate_base_fee_per_gas, compute_receipts_root, compute_transactions_root, @@ -26,7 +26,7 @@ use ethrex_common::{ use ethrex_crypto::NativeCrypto; use ethrex_crypto::keccak::Keccak256; -use ethrex_vm::{Evm, EvmError}; +use ethrex_vm::{Evm, EvmError, check_2d_gas_allowance}; use ethrex_rlp::encode::RLPEncode; use ethrex_storage::{Store, error::StoreError}; @@ -461,8 +461,8 @@ impl Blockchain { .chain_config() .is_amsterdam_activated(context.payload.header.timestamp) { - #[allow(clippy::cast_possible_truncation)] - let post_tx_index = (context.payload.body.transactions.len() + 1) as u16; + let post_tx_index = + u32::try_from(context.payload.body.transactions.len() + 1).unwrap_or(u32::MAX); context.vm.set_bal_index(post_tx_index); // Record withdrawal recipients as touched addresses per EIP-7928 if let Some(recorder) = context.vm.db.bal_recorder_mut() @@ -650,61 +650,92 @@ impl Blockchain { continue; } - // Set BAL index for this transaction (1-indexed per EIP-7928) - // Index is based on current transaction count + 1 - // Must happen BEFORE tx_checkpoint: set_bal_index flushes net-zero - // filters for the previous (committed) tx, which may insert reads. - #[allow(clippy::cast_possible_truncation)] - let tx_index = (context.payload.body.transactions.len() + 1) as u16; - context.vm.set_bal_index(tx_index); - - // EIP-7928: Lightweight tx-level checkpoint before trying the tx. - // If the tx is rejected, restore so only included txs affect the BAL. - // Taken after set_bal_index (which flushes previous tx) but before - // this tx's touches, so rejected txs leave no trace. - let bal_checkpoint = context - .vm - .db - .bal_recorder - .as_ref() - .map(|r| r.tx_checkpoint()); - - // Record tx sender and recipient for BAL - if let Some(recorder) = context.vm.db.bal_recorder_mut() { - recorder.record_touched_address(head_tx.tx.sender()); - if let TxKind::Call(to) = head_tx.to() { - recorder.record_touched_address(to); - } + match self.apply_tx_to_payload(head_tx, context) { + Ok(()) => txs.shift()?, + Err(_) => txs.pop(), } + } + Ok(()) + } - // Execute tx - let receipt = match self.apply_transaction(&head_tx, context) { - Ok(receipt) => { - txs.shift()?; - metrics!(METRICS_TX.inc_tx_with_type(MetricsTxType(head_tx.tx_type()))); - receipt - } - // Ignore following txs from sender - Err(e) => { - debug!("Failed to execute transaction: {tx_hash:x}, {e}"); - metrics!(METRICS_TX.inc_tx_errors(e.to_metric())); - // Restore BAL recorder to pre-tx state so rejected txs - // don't pollute the block access list. - if let (Some(recorder), Some(checkpoint)) = - (context.vm.db.bal_recorder_mut(), bal_checkpoint) - { - recorder.tx_restore(checkpoint); - } - txs.pop(); - continue; - } - }; - // Add transaction to block - debug!("Adding transaction: {} to payload", tx_hash); - context.payload.body.transactions.push(head_tx.into()); - // Save receipt for hash calculation - context.receipts.push(receipt); + /// Apply a single transaction to the in-progress payload. + /// + /// Runs the full per-tx pipeline: EIP-8037 2D inclusion check, EIP-7928 + /// BAL index/checkpoint setup, sender/recipient recording, dispatch to + /// blob/plain execution, and on failure rolls the BAL recorder back so + /// rejected txs leave no trace. On success the tx is appended to the + /// payload body and the receipt to `context.receipts`. + /// + /// Caller is responsible for mempool bookkeeping (advancing or dropping + /// the sender's queue) โ€” this function only mutates the payload context. + pub fn apply_tx_to_payload( + &self, + head: HeadTransaction, + context: &mut PayloadBuildContext, + ) -> Result<(), ChainError> { + let tx_hash = head.tx.hash(); + + // EIP-8037 (Amsterdam+, PR #2703): per-tx 2D inclusion check against + // running block totals. Run BEFORE we touch the BAL recorder so a + // rejected tx doesn't even produce a sender/recipient touch. + if context.is_amsterdam + && let Err(e) = check_2d_gas_allowance( + &head.tx, + Fork::Amsterdam, + context.block_regular_gas_used, + context.block_state_gas_used, + context.payload.header.gas_limit, + ) + { + debug!("Skipping tx {tx_hash:x}: fails 2D inclusion check: {e}"); + return Err(e.into()); } + + // Set BAL index for this transaction (1-indexed per EIP-7928). + // Must happen BEFORE tx_checkpoint: set_bal_index flushes net-zero + // filters for the previous (committed) tx, which may insert reads. + let tx_index = + u32::try_from(context.payload.body.transactions.len() + 1).unwrap_or(u32::MAX); + context.vm.set_bal_index(tx_index); + + // EIP-7928: lightweight tx-level checkpoint before trying the tx. + // If the tx is rejected, restore so only included txs affect the BAL. + // Taken after set_bal_index (which flushes previous tx) but before + // this tx's touches, so rejected txs leave no trace. + let bal_checkpoint = context + .vm + .db + .bal_recorder + .as_ref() + .map(|r| r.tx_checkpoint()); + + if let Some(recorder) = context.vm.db.bal_recorder_mut() { + recorder.record_touched_address(head.tx.sender()); + if let TxKind::Call(to) = head.to() { + recorder.record_touched_address(to); + } + } + + let receipt = match self.apply_transaction(&head, context) { + Ok(receipt) => { + metrics!(METRICS_TX.inc_tx_with_type(MetricsTxType(head.tx_type()))); + receipt + } + Err(e) => { + debug!("Failed to execute transaction: {tx_hash:x}, {e}"); + metrics!(METRICS_TX.inc_tx_errors(e.to_metric())); + if let (Some(recorder), Some(checkpoint)) = + (context.vm.db.bal_recorder_mut(), bal_checkpoint) + { + recorder.tx_restore(checkpoint); + } + return Err(e); + } + }; + + debug!("Adding transaction: {} to payload", tx_hash); + context.payload.body.transactions.push(head.into()); + context.receipts.push(receipt); Ok(()) } @@ -848,7 +879,18 @@ pub fn apply_plain_transaction( // 2. Revert cumulative gas counter inflation // This ensures the next transaction executes against clean state. context.vm.undo_last_tx()?; - context.cumulative_gas_spent -= report.gas_spent; + // `cumulative_gas_spent` was bumped inside `execute_tx` above; revert it + // now that the tx is being rejected. Use `saturating_sub` as a defensive + // guard โ€” cumulative must always dominate this tx's contribution unless + // some upstream bug leaks a stale value, in which case we'd rather clamp + // to 0 than underflow the counter. + debug_assert!( + context.cumulative_gas_spent >= report.gas_spent, + "cumulative_gas_spent underflow on tx rollback" + ); + context.cumulative_gas_spent = context + .cumulative_gas_spent + .saturating_sub(report.gas_spent); return Err(EvmError::Custom(format!( "block gas limit exceeded (state gas overflow): \ diff --git a/crates/common/errors.rs b/crates/common/errors.rs index 5464ae778f7..fb6b7211fc6 100644 --- a/crates/common/errors.rs +++ b/crates/common/errors.rs @@ -10,7 +10,7 @@ pub enum InvalidBlockError { #[error("Block access list hash does not match the one in the header after executing")] BlockAccessListHashMismatch, #[error("Block access list contains index {index} exceeding max valid index {max}")] - BlockAccessListIndexOutOfBounds { index: u16, max: u16 }, + BlockAccessListIndexOutOfBounds { index: u32, max: u32 }, #[error("Block access list exceeds gas limit, {items} items exceeds limit of {max_items}")] BlockAccessListSizeExceeded { items: u64, max_items: u64 }, #[error("World State Root does not match the one in the header after executing")] diff --git a/crates/common/types/block_access_list.rs b/crates/common/types/block_access_list.rs index 319bbebaff0..ff043a3e867 100644 --- a/crates/common/types/block_access_list.rs +++ b/crates/common/types/block_access_list.rs @@ -45,14 +45,14 @@ fn sorted_list_length(items: &[T]) -> usize { #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct StorageChange { - /// Block access index per EIP-7928 spec (uint16). - pub block_access_index: u16, + /// Block access index per EIP-7928 spec (uint32). + pub block_access_index: u32, pub post_value: U256, } impl StorageChange { /// Creates a new storage change with the given block access index and post value. - pub fn new(block_access_index: u16, post_value: U256) -> Self { + pub fn new(block_access_index: u32, post_value: U256) -> Self { Self { block_access_index, post_value, @@ -135,14 +135,14 @@ impl RLPDecode for SlotChange { #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct BalanceChange { - /// Block access index per EIP-7928 spec (uint16). - pub block_access_index: u16, + /// Block access index per EIP-7928 spec (uint32). + pub block_access_index: u32, pub post_balance: U256, } impl BalanceChange { /// Creates a new balance change with the given block access index and post balance. - pub fn new(block_access_index: u16, post_balance: U256) -> Self { + pub fn new(block_access_index: u32, post_balance: U256) -> Self { Self { block_access_index, post_balance, @@ -177,14 +177,14 @@ impl RLPDecode for BalanceChange { #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct NonceChange { - /// Block access index per EIP-7928 spec (uint16). - pub block_access_index: u16, + /// Block access index per EIP-7928 spec (uint32). + pub block_access_index: u32, pub post_nonce: u64, } impl NonceChange { /// Creates a new nonce change with the given block access index and post nonce. - pub fn new(block_access_index: u16, post_nonce: u64) -> Self { + pub fn new(block_access_index: u32, post_nonce: u64) -> Self { Self { block_access_index, post_nonce, @@ -219,14 +219,14 @@ impl RLPDecode for NonceChange { #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct CodeChange { - /// Block access index per EIP-7928 spec (uint16). - pub block_access_index: u16, + /// Block access index per EIP-7928 spec (uint32). + pub block_access_index: u32, pub new_code: Bytes, } impl CodeChange { /// Creates a new code change with the given block access index and new code. - pub fn new(block_access_index: u16, new_code: Bytes) -> Self { + pub fn new(block_access_index: u32, new_code: Bytes) -> Self { Self { block_access_index, new_code, @@ -558,8 +558,8 @@ impl BlockAccessList { pub fn build_validation_index(&self) -> BalAddressIndex { let mut addr_to_idx = FxHashMap::with_capacity_and_hasher(self.inner.len(), Default::default()); - let mut tx_to_accounts: FxHashMap> = FxHashMap::default(); - let mut accounts_by_min_index: Vec<(u16, usize)> = Vec::new(); + let mut tx_to_accounts: FxHashMap> = FxHashMap::default(); + let mut accounts_by_min_index: Vec<(u32, usize)> = Vec::new(); for (i, acct) in self.inner.iter().enumerate() { addr_to_idx.insert(acct.address, i); @@ -606,15 +606,15 @@ pub struct BalAddressIndex { /// Maps each address in the BAL to its index in `BlockAccessList.inner`. pub addr_to_idx: FxHashMap, /// For each block_access_index, the BAL-inner indices with changes at that index. - pub tx_to_accounts: FxHashMap>, + pub tx_to_accounts: FxHashMap>, /// BAL-inner indices sorted by their minimum block_access_index. /// Used by `seed_db_from_bal` to skip accounts with no changes at indices <= max_idx. /// Only includes accounts that have at least one mutation (balance/nonce/code/storage write). - pub accounts_by_min_index: Vec<(u16, usize)>, + pub accounts_by_min_index: Vec<(u32, usize)>, } /// Binary search for exact match at `idx` in balance changes (sorted by block_access_index). -pub fn find_exact_change_balance(changes: &[BalanceChange], idx: u16) -> Option { +pub fn find_exact_change_balance(changes: &[BalanceChange], idx: u32) -> Option { let pos = changes.partition_point(|c| c.block_access_index < idx); if pos < changes.len() && changes[pos].block_access_index == idx { Some(changes[pos].post_balance) @@ -624,13 +624,13 @@ pub fn find_exact_change_balance(changes: &[BalanceChange], idx: u16) -> Option< } /// Returns true if there is a balance change exactly at `idx`. -pub fn has_exact_change_balance(changes: &[BalanceChange], idx: u16) -> bool { +pub fn has_exact_change_balance(changes: &[BalanceChange], idx: u32) -> bool { let pos = changes.partition_point(|c| c.block_access_index < idx); pos < changes.len() && changes[pos].block_access_index == idx } /// Binary search for exact match at `idx` in nonce changes. -pub fn find_exact_change_nonce(changes: &[NonceChange], idx: u16) -> Option { +pub fn find_exact_change_nonce(changes: &[NonceChange], idx: u32) -> Option { let pos = changes.partition_point(|c| c.block_access_index < idx); if pos < changes.len() && changes[pos].block_access_index == idx { Some(changes[pos].post_nonce) @@ -640,13 +640,13 @@ pub fn find_exact_change_nonce(changes: &[NonceChange], idx: u16) -> Option } /// Returns true if there is a nonce change exactly at `idx`. -pub fn has_exact_change_nonce(changes: &[NonceChange], idx: u16) -> bool { +pub fn has_exact_change_nonce(changes: &[NonceChange], idx: u32) -> bool { let pos = changes.partition_point(|c| c.block_access_index < idx); pos < changes.len() && changes[pos].block_access_index == idx } /// Binary search for exact match at `idx` in code changes. -pub fn find_exact_change_code(changes: &[CodeChange], idx: u16) -> Option<&Bytes> { +pub fn find_exact_change_code(changes: &[CodeChange], idx: u32) -> Option<&Bytes> { let pos = changes.partition_point(|c| c.block_access_index < idx); if pos < changes.len() && changes[pos].block_access_index == idx { Some(&changes[pos].new_code) @@ -656,13 +656,13 @@ pub fn find_exact_change_code(changes: &[CodeChange], idx: u16) -> Option<&Bytes } /// Returns true if there is a code change exactly at `idx`. -pub fn has_exact_change_code(changes: &[CodeChange], idx: u16) -> bool { +pub fn has_exact_change_code(changes: &[CodeChange], idx: u32) -> bool { let pos = changes.partition_point(|c| c.block_access_index < idx); pos < changes.len() && changes[pos].block_access_index == idx } /// Binary search for exact match at `idx` in storage changes. -pub fn find_exact_change_storage(changes: &[StorageChange], idx: u16) -> Option { +pub fn find_exact_change_storage(changes: &[StorageChange], idx: u32) -> Option { let pos = changes.partition_point(|c| c.block_access_index < idx); if pos < changes.len() && changes[pos].block_access_index == idx { Some(changes[pos].post_value) @@ -672,7 +672,7 @@ pub fn find_exact_change_storage(changes: &[StorageChange], idx: u16) -> Option< } /// Returns true if there is a storage change exactly at `idx`. -pub fn has_exact_change_storage(changes: &[StorageChange], idx: u16) -> bool { +pub fn has_exact_change_storage(changes: &[StorageChange], idx: u32) -> bool { let pos = changes.partition_point(|c| c.block_access_index < idx); pos < changes.len() && changes[pos].block_access_index == idx } @@ -719,7 +719,7 @@ pub struct BlockAccessListCheckpoint { #[derive(Debug)] pub struct TxCheckpoint { inner: BlockAccessListCheckpoint, - current_index: u16, + current_index: u32, touched_addresses_len: usize, storage_reads_lens: IndexMap, initial_balances_len: usize, @@ -737,9 +737,9 @@ pub struct TxCheckpoint { /// - n+1: Post-execution phase (withdrawals) #[derive(Debug, Default, Clone)] pub struct BlockAccessListRecorder { - /// Current block access index per EIP-7928 spec (uint16). + /// Current block access index per EIP-7928 spec (uint32). /// 0=pre-exec, 1..n=tx indices, n+1=post-exec. - current_index: u16, + current_index: u32, /// All addresses that must be in BAL (touched during execution). /// IndexSet for O(1) insert/lookup and length-based tx-level checkpoint/restore. touched_addresses: IndexSet
, @@ -747,7 +747,7 @@ pub struct BlockAccessListRecorder { /// IndexMap/IndexSet for length-based tx-level checkpoint/restore. storage_reads: IndexMap>, /// Storage writes per address (slot -> list of (index, post_value) pairs). - storage_writes: BTreeMap>>, + storage_writes: BTreeMap>>, /// Initial balances for detecting balance round-trips. /// IndexMap for length-based tx-level checkpoint/restore. initial_balances: IndexMap, @@ -761,11 +761,11 @@ pub struct BlockAccessListRecorder { /// pre-transaction code (e.g., delegate then reset), it MUST NOT be recorded. tx_initial_code: BTreeMap, /// Balance changes per address (list of (index, post_balance) pairs). - balance_changes: BTreeMap>, + balance_changes: BTreeMap>, /// Nonce changes per address (list of (index, post_nonce) pairs). - nonce_changes: BTreeMap>, + nonce_changes: BTreeMap>, /// Code changes per address (list of (index, new_code) pairs). - code_changes: BTreeMap>, + code_changes: BTreeMap>, /// Addresses that had non-empty code at the start (before any code changes). /// IndexSet for length-based tx-level checkpoint/restore. addresses_with_initial_code: IndexSet
, @@ -785,12 +785,12 @@ impl BlockAccessListRecorder { Self::default() } - /// Sets the current block access index per EIP-7928 spec (uint16). + /// Sets the current block access index per EIP-7928 spec (uint32). /// Call this before each transaction (index 1..n) and for withdrawals (n+1). /// /// Filters net-zero storage writes and code changes for the current transaction /// before switching to a new transaction index. - pub fn set_block_access_index(&mut self, index: u16) { + pub fn set_block_access_index(&mut self, index: u32) { // Filter net-zero changes and clear per-transaction initial values when switching transactions if self.current_index != index { // Filter net-zero storage writes and code changes for the current transaction before switching @@ -905,8 +905,8 @@ impl BlockAccessListRecorder { } } - /// Returns the current block access index per EIP-7928 spec (uint16). - pub fn current_index(&self) -> u16 { + /// Returns the current block access index per EIP-7928 spec (uint32). + pub fn current_index(&self) -> u32 { self.current_index } @@ -922,6 +922,27 @@ impl BlockAccessListRecorder { self.in_system_call = false; } + /// Consumes and returns the touched-addresses set. + /// Used by parallel BAL validation (shadow recorder) to diff against the header BAL. + pub fn take_touched_addresses(&mut self) -> Vec
{ + std::mem::take(&mut self.touched_addresses) + .into_iter() + .collect() + } + + /// Consumes and returns recorded storage reads as `(address, slot)` pairs. + /// Excludes slots that were later written (they get promoted to `storage_writes`). + pub fn take_storage_reads(&mut self) -> Vec<(Address, U256)> { + let reads = std::mem::take(&mut self.storage_reads); + let mut out = Vec::new(); + for (addr, slots) in reads { + for slot in slots { + out.push((addr, slot)); + } + } + out + } + /// Records an address as touched during execution. /// The address will appear in the BAL even if it has no state changes. /// @@ -1148,7 +1169,7 @@ impl BlockAccessListRecorder { if let Some(slots) = self.storage_writes.get(address) { for (slot, changes) in slots { let mut slot_change = SlotChange::new(*slot); - let mut deduped: BTreeMap = BTreeMap::new(); + let mut deduped: BTreeMap = BTreeMap::new(); for (index, post_value) in changes { deduped.insert(*index, *post_value); } @@ -1183,7 +1204,7 @@ impl BlockAccessListRecorder { // change MUST NOT be recorded." if let Some(changes) = self.balance_changes.get(address) { // Group balance changes by transaction index - let mut changes_by_tx: BTreeMap> = BTreeMap::new(); + let mut changes_by_tx: BTreeMap> = BTreeMap::new(); for (index, post_balance) in changes { changes_by_tx.entry(*index).or_default().push(*post_balance); } @@ -1216,7 +1237,7 @@ impl BlockAccessListRecorder { // Per EIP-7928, similar to balance changes, we only record the final nonce per tx. if let Some(changes) = self.nonce_changes.get(address) { // Group nonce changes by transaction index - let mut changes_by_tx: BTreeMap = BTreeMap::new(); + let mut changes_by_tx: BTreeMap = BTreeMap::new(); for (index, post_nonce) in changes { // Only keep the final nonce for each transaction (last write wins) changes_by_tx.insert(*index, *post_nonce); @@ -1231,7 +1252,7 @@ impl BlockAccessListRecorder { // Per EIP-7928, similar to nonce/balance, we only record the final code per tx. if let Some(changes) = self.code_changes.get(address) { // Group code changes by transaction index, keeping only the final one - let mut changes_by_tx: BTreeMap = BTreeMap::new(); + let mut changes_by_tx: BTreeMap = BTreeMap::new(); for (index, new_code) in changes { // Only keep the final code for each transaction (last write wins) changes_by_tx.insert(*index, new_code.clone()); diff --git a/crates/common/types/genesis.rs b/crates/common/types/genesis.rs index c9e304a0f28..5248ff13f7a 100644 --- a/crates/common/types/genesis.rs +++ b/crates/common/types/genesis.rs @@ -719,7 +719,11 @@ impl Genesis { self.block_access_list_hash .unwrap_or(*EMPTY_BLOCK_ACCESS_LIST_HASH), ); - let slot_number = self.slot_number; + + let slot_number = self + .config + .is_amsterdam_activated(self.timestamp) + .then_some(self.slot_number.unwrap_or(0)); BlockHeader { parent_hash: H256::zero(), diff --git a/crates/common/validation.rs b/crates/common/validation.rs index 201e49f4497..649b8138f64 100644 --- a/crates/common/validation.rs +++ b/crates/common/validation.rs @@ -117,8 +117,8 @@ pub fn validate_requests_hash( /// Helper to validate that all indices in an iterator are within bounds. fn validate_bal_indices( - indices: impl Iterator, - max_valid_index: u16, + indices: impl Iterator, + max_valid_index: u32, ) -> Result<(), InvalidBlockError> { for index in indices { if index > max_valid_index { @@ -139,8 +139,7 @@ pub fn validate_header_bal_indices( bal: &crate::types::block_access_list::BlockAccessList, transaction_count: usize, ) -> Result<(), InvalidBlockError> { - #[allow(clippy::cast_possible_truncation)] - let max_valid_index = transaction_count as u16 + 1; + let max_valid_index = u32::try_from(transaction_count + 1).unwrap_or(u32::MAX); for account in bal.accounts() { validate_bal_indices( @@ -184,8 +183,7 @@ pub fn validate_block_access_list_hash( // Per EIP-7928: "Invalidate block if access list...contains indices exceeding len(transactions) + 1" // Index semantics: 0=pre-exec, 1..n=tx indices, n+1=post-exec (withdrawals) - #[allow(clippy::cast_possible_truncation)] - let max_valid_index = transaction_count as u16 + 1; + let max_valid_index = u32::try_from(transaction_count + 1).unwrap_or(u32::MAX); // Validate all indices and compute item count in a single pass over the BAL. let mut bal_items: u64 = 0; diff --git a/crates/l2/sequencer/block_producer/payload_builder.rs b/crates/l2/sequencer/block_producer/payload_builder.rs index ff2de93ba42..937eea1b0b3 100644 --- a/crates/l2/sequencer/block_producer/payload_builder.rs +++ b/crates/l2/sequencer/block_producer/payload_builder.rs @@ -6,7 +6,9 @@ use ethrex_blockchain::{ }; use ethrex_common::{ U256, - types::{Block, EIP1559_DEFAULT_SERIALIZED_LENGTH, SAFE_BYTES_PER_BLOB, Transaction, TxKind}, + types::{ + Block, EIP1559_DEFAULT_SERIALIZED_LENGTH, Fork, SAFE_BYTES_PER_BLOB, Transaction, TxKind, + }, }; use ethrex_l2_common::{ messages::get_block_l2_out_messages, privileged_transactions::PRIVILEGED_TX_BUDGET, @@ -20,6 +22,7 @@ use ethrex_metrics::{ }; use ethrex_rlp::encode::RLPEncode; use ethrex_storage::Store; +use ethrex_vm::check_2d_gas_allowance; use std::sync::Arc; use std::{collections::HashMap, ops::Div}; use tokio::time::Instant; @@ -110,6 +113,14 @@ pub async fn fill_transactions( let chain_config = store.get_chain_config(); let chain_id = chain_config.chain_id; + // EIP-8037 (Amsterdam+): the tx inclusion check enforces a 2D budget per + // tx so a transaction's worst-case contribution in either dimension fits + // in the remaining block budget. Gate on the block's timestamp and apply + // in the inclusion loop below; the L2 builder uses + // `configured_block_gas_limit` (possibly tighter than + // `payload.header.gas_limit`) as the limit, keeping L2 tighter than L1. + let is_amsterdam = chain_config.is_amsterdam_activated(context.payload.header.timestamp); + debug!("Fetching transactions from mempool"); // Fetch mempool transactions let latest_block_number = store.get_latest_block_number().await?; @@ -209,11 +220,41 @@ pub async fn fill_transactions( continue; } + // EIP-8037 (Amsterdam+, PR #2703): per-tx 2D inclusion check against + // running block totals, using the L2-configured block gas limit + // (which may be tighter than the header's). Must run BEFORE we touch + // the BAL recorder so a rejected tx doesn't leave a sender/recipient + // touch in the BAL. + if is_amsterdam + && let Err(e) = check_2d_gas_allowance( + &head_tx.tx, + Fork::Amsterdam, + context.block_regular_gas_used, + context.block_state_gas_used, + configured_block_gas_limit, + ) + { + debug!("Skipping tx {tx_hash:#x}: fails 2D inclusion check: {e}"); + txs.pop(); + continue; + } + // Set BAL index for this transaction (1-indexed per EIP-7928) - #[allow(clippy::cast_possible_truncation, clippy::as_conversions)] - let tx_index = (context.payload.body.transactions.len() + 1) as u16; + let tx_index = + u32::try_from(context.payload.body.transactions.len() + 1).unwrap_or(u32::MAX); context.vm.set_bal_index(tx_index); + // EIP-7928: tx-level BAL checkpoint before any touches. Taken AFTER + // set_bal_index (which flushes the previous committed tx's net-zero + // filter) but BEFORE this tx's sender/recipient touches, so a rejected + // tx leaves no trace in the BAL. Matches the L1 builder pattern. + let bal_checkpoint = context + .vm + .db + .bal_recorder + .as_ref() + .map(|r| r.tx_checkpoint()); + // Record tx sender and recipient for BAL if let Some(recorder) = context.vm.db.bal_recorder_mut() { recorder.record_touched_address(head_tx.tx.sender()); @@ -222,15 +263,28 @@ pub async fn fill_transactions( } } - // Execute tx + // Execute tx. Snapshot every PayloadBuildContext counter that + // `apply_plain_transaction` mutates so the invalid-L2-message rollback + // below can fully undo a tx's effect. Amsterdam's 2D accounting adds + // `block_regular_gas_used` / `block_state_gas_used` to the set that + // drive `gas_used()` and the final header `gas_used`. let previous_remaining_gas = context.remaining_gas; let previous_block_value = context.block_value; let previous_cumulative_gas_spent = context.cumulative_gas_spent; + let previous_block_regular_gas_used = context.block_regular_gas_used; + let previous_block_state_gas_used = context.block_state_gas_used; let receipt = match apply_plain_transaction(&head_tx, context) { Ok(receipt) => receipt, Err(e) => { debug!("Failed to execute transaction: {}, {e}", tx_hash); metrics!(METRICS_TX.inc_tx_errors(e.to_metric())); + // Restore BAL recorder so the rejected tx contributes nothing + // to the block access list. + if let (Some(recorder), Some(checkpoint)) = + (context.vm.db.bal_recorder_mut(), bal_checkpoint) + { + recorder.tx_restore(checkpoint); + } // Ignore following txs from sender txs.pop(); continue; @@ -246,6 +300,18 @@ pub async fn fill_transactions( context.remaining_gas = previous_remaining_gas; context.block_value = previous_block_value; context.cumulative_gas_spent = previous_cumulative_gas_spent; + // Amsterdam 2D accounting: restore the per-dimension counters + // too. Without this, phantom gas from the rejected tx stays in + // the payload context and skews subsequent inclusion decisions + // plus the final header `gas_used`. + context.block_regular_gas_used = previous_block_regular_gas_used; + context.block_state_gas_used = previous_block_state_gas_used; + // Roll back BAL touches from the aborted tx. + if let (Some(recorder), Some(checkpoint)) = + (context.vm.db.bal_recorder_mut(), bal_checkpoint) + { + recorder.tx_restore(checkpoint); + } found_invalid_message = true; break; } diff --git a/crates/networking/rpc/engine/fork_choice.rs b/crates/networking/rpc/engine/fork_choice.rs index c59e9b72f75..6646fa58750 100644 --- a/crates/networking/rpc/engine/fork_choice.rs +++ b/crates/networking/rpc/engine/fork_choice.rs @@ -318,9 +318,23 @@ async fn handle_forkchoice( } Err(forkchoice_error) => { let forkchoice_response = match forkchoice_error { - InvalidForkChoice::NewHeadAlreadyCanonical => ForkChoiceResponse::from( - PayloadStatus::valid_with_hash(fork_choice_state.head_block_hash), - ), + InvalidForkChoice::NewHeadAlreadyCanonical => { + // The fork-choice was effectively accepted: head is canonical and + // points to a known block. Treat it like the Ok(head) branch: + // - mark the node synced so eth_syncing reports `false`, + // - return the head header so the caller can build a payload + // when payloadAttributes is non-null (engine API spec). + context.blockchain.set_synced(); + let head_block = context + .storage + .get_block_header_by_hash(fork_choice_state.head_block_hash)?; + return Ok(( + head_block, + ForkChoiceResponse::from(PayloadStatus::valid_with_hash( + fork_choice_state.head_block_hash, + )), + )); + } InvalidForkChoice::Syncing => { // Start sync syncer.sync_to_head(fork_choice_state.head_block_hash); @@ -335,6 +349,10 @@ async fn handle_forkchoice( warn!("Invalid fork choice state. Reason: {:?}", forkchoice_error); return Err(RpcErr::InvalidForkChoiceState(forkchoice_error.to_string())); } + InvalidForkChoice::TooDeepReorg { .. } => { + warn!("Rejecting fork choice update. Reason: {forkchoice_error}"); + return Err(RpcErr::TooDeepReorg(forkchoice_error.to_string())); + } InvalidForkChoice::InvalidAncestor(last_valid_hash) => { ForkChoiceResponse::from(PayloadStatus::invalid_with( last_valid_hash, diff --git a/crates/networking/rpc/rpc.rs b/crates/networking/rpc/rpc.rs index 041242bab90..caa46ed9ca0 100644 --- a/crates/networking/rpc/rpc.rs +++ b/crates/networking/rpc/rpc.rs @@ -397,6 +397,7 @@ fn get_error_kind(err: &RpcErr) -> &'static str { RpcErr::AuthenticationError(_) => "AuthenticationError", RpcErr::InvalidForkChoiceState(_) => "InvalidForkChoiceState", RpcErr::InvalidPayloadAttributes(_) => "InvalidPayloadAttributes", + RpcErr::TooDeepReorg(_) => "TooDeepReorg", RpcErr::UnknownPayload(_) => "UnknownPayload", RpcErr::InvalidProofFormat(_) => "InvalidProofFormat", RpcErr::InvalidHeaderFormat(_) => "InvalidHeaderFormat", diff --git a/crates/networking/rpc/utils.rs b/crates/networking/rpc/utils.rs index 780ba93b9c7..220d0f397c8 100644 --- a/crates/networking/rpc/utils.rs +++ b/crates/networking/rpc/utils.rs @@ -22,7 +22,7 @@ use ethrex_blockchain::error::MempoolError; /// - `-32602`: Invalid params /// - `-32603`: Internal error /// - `-32000`: Generic server error -/// - `-38001` to `-38005`: Engine API specific errors +/// - `-38001` to `-38006`: Engine API specific errors /// - `3`: Execution reverted/halted #[derive(Debug, thiserror::Error)] pub enum RpcErr { @@ -54,6 +54,8 @@ pub enum RpcErr { InvalidForkChoiceState(String), #[error("Invalid payload attributes: {0}")] InvalidPayloadAttributes(String), + #[error("Too deep reorg: {0}")] + TooDeepReorg(String), #[error("Unknown payload: {0}")] UnknownPayload(String), // EIP-8025 proof errors (-39001 .. -39004) @@ -161,6 +163,11 @@ impl From for RpcErrorMetadata { data: Some(data), message: "Invalid payload attributes".to_string(), }, + RpcErr::TooDeepReorg(data) => RpcErrorMetadata { + code: -38006, + data: Some(data), + message: "Too deep reorg".to_string(), + }, RpcErr::UnknownPayload(context) => RpcErrorMetadata { code: -38001, data: None, diff --git a/crates/vm/backends/levm/mod.rs b/crates/vm/backends/levm/mod.rs index e9c178d168b..9ed80d15e00 100644 --- a/crates/vm/backends/levm/mod.rs +++ b/crates/vm/backends/levm/mod.rs @@ -32,6 +32,7 @@ use ethrex_levm::account::{AccountStatus, LevmAccount}; use ethrex_levm::call_frame::Stack; use ethrex_levm::constants::{ POST_OSAKA_GAS_LIMIT_CAP, STACK_LIMIT, SYS_CALL_GAS_LIMIT, TX_BASE_COST, + TX_MAX_GAS_LIMIT_AMSTERDAM, }; use ethrex_levm::db::Database; use ethrex_levm::db::gen_db::{CacheDB, GeneralizedDatabase}; @@ -40,6 +41,7 @@ use ethrex_levm::errors::{InternalError, TxValidationError}; use ethrex_levm::timings::{OPCODE_TIMINGS, PRECOMPILES_TIMINGS}; use ethrex_levm::tracing::LevmCallTracer; use ethrex_levm::utils::get_base_fee_per_blob_gas; +use ethrex_levm::utils::intrinsic_gas_dimensions; use ethrex_levm::vm::VMType; use ethrex_levm::{ Environment, @@ -76,6 +78,62 @@ fn check_gas_limit( Ok(()) } +/// EIP-8037 (Amsterdam+, execution-specs PR #2703) per-tx 2D inclusion check. +/// +/// A tx is rejected (block invalid) if its worst-case contribution to either +/// dimension exceeds the remaining budget at tx inclusion time: +/// +/// - regular dim: `min(TX_MAX_GAS_LIMIT, tx.gas - intrinsic.state) > block_gas_limit - block_regular_gas_used` +/// - state dim: `tx.gas - intrinsic.regular > block_gas_limit - block_state_gas_used` +/// +/// Mirrors `src/ethereum/forks/amsterdam/fork.py:560-578` at eels_commit `524b446`. +/// +/// Note: `block_gas_used_regular` here equals EELS's `block_output.block_gas_used` +/// because our `report.gas_used` already reflects `max(raw_regular, calldata_floor)` +/// per-tx โ€” i.e. the floor is applied before aggregation, not after. Keep this in +/// sync with the aggregation loop in [`execute_block_parallel`]. +pub fn check_2d_gas_allowance( + tx: &Transaction, + fork: Fork, + block_gas_used_regular: u64, + block_gas_used_state: u64, + block_gas_limit: u64, +) -> Result<(), EvmError> { + let (intrinsic_regular, intrinsic_state) = intrinsic_gas_dimensions(tx, fork, block_gas_limit) + .map_err(|e| EvmError::Transaction(format!("intrinsic gas computation failed: {e}")))?; + + let tx_gas = tx.gas_limit(); + let regular_available = block_gas_limit.saturating_sub(block_gas_used_regular); + let state_available = block_gas_limit.saturating_sub(block_gas_used_state); + + // Regular dim: worst-case regular contribution = tx.gas - intrinsic.state, + // capped at TX_MAX_GAS_LIMIT. If tx.gas < intrinsic.state the tx is + // intrinsic-underfunded and will be rejected later; treat the subtraction + // as zero so the 2D check doesn't spuriously reject on saturation. + let regular_contrib = tx_gas + .saturating_sub(intrinsic_state) + .min(TX_MAX_GAS_LIMIT_AMSTERDAM); + if regular_contrib > regular_available { + return Err(EvmError::Transaction(format!( + "Gas allowance exceeded: regular dim worst-case {regular_contrib} > \ + available {regular_available} (block_gas_used_regular={block_gas_used_regular}, \ + block_gas_limit={block_gas_limit})" + ))); + } + + // State dim: worst-case state contribution = tx.gas - intrinsic.regular. + let state_contrib = tx_gas.saturating_sub(intrinsic_regular); + if state_contrib > state_available { + return Err(EvmError::Transaction(format!( + "Gas allowance exceeded: state dim worst-case {state_contrib} > \ + available {state_available} (block_gas_used_state={block_gas_used_state}, \ + block_gas_limit={block_gas_limit})" + ))); + } + + Ok(()) +} + /// Error type for BAL validation failures, distinguishing state mismatches /// from database errors. #[derive(Debug, thiserror::Error)] @@ -100,6 +158,15 @@ impl LEVM { let chain_config = db.store.get_chain_config()?; let is_amsterdam = chain_config.is_amsterdam_activated(block.header.timestamp); + // EIP-7928 BlockAccessIndex is uint32. Block validity forbids >= 2^32 txs + // long before we'd reach this point, but guard the invariant explicitly + // so any upstream bug that inflates tx counts panics in debug instead of + // silently producing a `u32::MAX` index. + debug_assert!( + block.body.transactions.len() < u32::MAX as usize, + "tx count overflows u32 BlockAccessIndex" + ); + // Enable BAL recording for Amsterdam+ forks if is_amsterdam { db.enable_bal_recording(); @@ -136,10 +203,21 @@ impl LEVM { check_gas_limit(cumulative_gas_used, tx.gas_limit(), block.header.gas_limit)?; } - // Set BAL index for this transaction (1-indexed per EIP-7928, uint16) + // EIP-8037 (Amsterdam+, PR #2703): per-tx 2D inclusion check. + if is_amsterdam { + check_2d_gas_allowance( + tx, + Fork::Amsterdam, + block_regular_gas_used, + block_state_gas_used, + block.header.gas_limit, + )?; + } + + // Set BAL index for this transaction (1-indexed per EIP-7928) if is_amsterdam { - #[allow(clippy::cast_possible_truncation)] - db.set_bal_index((tx_idx + 1) as u16); + let bal_index = u32::try_from(tx_idx + 1).unwrap_or(u32::MAX); + db.set_bal_index(bal_index); // Record tx sender and recipient for BAL if let Some(recorder) = db.bal_recorder_mut() { @@ -209,11 +287,11 @@ impl LEVM { ))); } - // Set BAL index for post-execution phase (requests + withdrawals, uint16) + // Set BAL index for post-execution phase (requests + withdrawals) // Order must match geth: requests (system calls) BEFORE withdrawals. if is_amsterdam { - #[allow(clippy::cast_possible_truncation)] - let post_tx_index = (block.body.transactions.len() + 1) as u16; + let post_tx_index = + u32::try_from(block.body.transactions.len() + 1).unwrap_or(u32::MAX); db.set_bal_index(post_tx_index); // Record ALL withdrawal recipients for BAL per EIP-7928: @@ -263,6 +341,12 @@ impl LEVM { let chain_config = db.store.get_chain_config()?; let is_amsterdam = chain_config.is_amsterdam_activated(block.header.timestamp); + // EIP-7928 BlockAccessIndex invariant โ€” see `execute_block` for rationale. + debug_assert!( + block.body.transactions.len() < u32::MAX as usize, + "tx count overflows u32 BlockAccessIndex" + ); + let transactions_with_sender = block .body @@ -281,7 +365,8 @@ impl LEVM { validate_header_bal_indices(bal, block.body.transactions.len()) .map_err(|e| EvmError::Custom(e.to_string()))?; - // No BAL recording needed: we have the header BAL, not building a new one + // Outer db has no BAL recorder: header BAL drives validation. + // Per-tx tx_dbs enable a shadow recorder for accessed-entry checks. Self::prepare_block(block, db, vm_type, crypto)?; // Build validation index once โ€” shared across parallel execution and post-exec seeding. @@ -312,8 +397,8 @@ impl LEVM { match parallel_result { Ok(result) => result, Err(parallel_err) => { - #[allow(clippy::cast_possible_truncation)] - let last_tx_idx = block.body.transactions.len() as u16; + let last_tx_idx = + u32::try_from(block.body.transactions.len()).unwrap_or(u32::MAX); if Self::seed_db_from_bal( db, bal, @@ -335,8 +420,7 @@ impl LEVM { // request extraction system calls see user-queued requests on predeploys. // Withdrawal index is n_txs+1 in BAL; we use n_txs to avoid double-applying // withdrawal balances (process_withdrawals handles those below). - #[allow(clippy::cast_possible_truncation)] - let last_tx_idx = block.body.transactions.len() as u16; + let last_tx_idx = u32::try_from(block.body.transactions.len()).unwrap_or(u32::MAX); Self::seed_db_from_bal( db, bal, @@ -359,9 +443,12 @@ impl LEVM { // not from db โ€” no need to call send_state_transitions_tx here. // Validate BAL entries at the withdrawal index against actual - // post-withdrawal/request state. - #[allow(clippy::cast_possible_truncation)] - let withdrawal_idx = (block.body.transactions.len() as u16) + 1; + // post-withdrawal/request state. `saturating_add(1)` prevents a + // release-build wrap if `n == u32::MAX` (debug_assert on tx count + // catches this upstream, but belt-and-braces). + let withdrawal_idx = u32::try_from(block.body.transactions.len()) + .map(|n| n.saturating_add(1)) + .unwrap_or(u32::MAX); Self::validate_bal_withdrawal_index(db, bal, withdrawal_idx, &validation_index)?; // Mark storage_reads that occurred during the withdrawal/request phase. @@ -384,6 +471,12 @@ impl LEVM { } } for addr in db.current_accounts_state.keys() { + // EIP-7928: SYSTEM_ADDRESS in db state comes from pre-exec system + // calls and doesn't legitimize a bare BAL entry โ€” the per-tx shadow + // recorder has already marked off user-tx touches. + if *addr == SYSTEM_ADDRESS { + continue; + } unaccessed_pure_accounts.remove(addr); } } @@ -454,10 +547,21 @@ impl LEVM { check_gas_limit(cumulative_gas_used, tx.gas_limit(), block.header.gas_limit)?; } - // Set BAL index for this transaction (1-indexed per EIP-7928, uint16) + // EIP-8037 (Amsterdam+, PR #2703): per-tx 2D inclusion check. if is_amsterdam { - #[allow(clippy::cast_possible_truncation)] - db.set_bal_index((tx_idx + 1) as u16); + check_2d_gas_allowance( + tx, + Fork::Amsterdam, + block_regular_gas_used, + block_state_gas_used, + block.header.gas_limit, + )?; + } + + // Set BAL index for this transaction (1-indexed per EIP-7928) + if is_amsterdam { + let bal_index = u32::try_from(tx_idx + 1).unwrap_or(u32::MAX); + db.set_bal_index(bal_index); // Record tx sender and recipient for BAL if let Some(recorder) = db.bal_recorder_mut() { @@ -551,11 +655,11 @@ impl LEVM { LEVM::send_state_transitions_tx(&merkleizer, db, queue_length)?; } - // Set BAL index for post-execution phase (requests + withdrawals, uint16) + // Set BAL index for post-execution phase (requests + withdrawals) // Order must match geth: requests (system calls) BEFORE withdrawals. if is_amsterdam { - #[allow(clippy::cast_possible_truncation)] - let post_tx_index = (block.body.transactions.len() + 1) as u16; + let post_tx_index = + u32::try_from(block.body.transactions.len() + 1).unwrap_or(u32::MAX); db.set_bal_index(post_tx_index); // Record ALL withdrawal recipients for BAL per EIP-7928 @@ -747,8 +851,8 @@ impl LEVM { fn seed_db_from_bal( db: &mut GeneralizedDatabase, bal: &BlockAccessList, - max_idx: u16, - accounts_by_min_index: &[(u16, usize)], + max_idx: u32, + accounts_by_min_index: &[(u32, usize)], ) -> Result<(), EvmError> { // Only visit accounts whose minimum change index <= max_idx. let end = accounts_by_min_index.partition_point(|(min_idx, _)| *min_idx <= max_idx); @@ -896,6 +1000,16 @@ impl LEVM { let store = db.store.clone(); let header = &block.header; let n_txs = txs_with_sender.len(); + // BAL-seeded parallel execution is only reachable on Amsterdam+ (callers + // gate on is_amsterdam before providing a header BAL). We recompute the + // flag here to gate the 2D inclusion check explicitly, keeping the + // invariant checkable rather than implicit. + let chain_config = store.get_chain_config()?; + let is_amsterdam = chain_config.is_amsterdam_activated(header.timestamp); + debug_assert!( + is_amsterdam, + "execute_block_parallel invoked on non-Amsterdam block" + ); // 1. Convert BAL โ†’ AccountUpdates and send to merkleizer (single batch) // This covers ALL state changes: system calls, txs, withdrawals. @@ -927,7 +1041,14 @@ impl LEVM { } // Mark pure-access accounts that were touched during system calls. + // EIP-7928: SYSTEM_ADDRESS is excluded from BAL entries created by system calls + // (only user-tx touches legitimize it). Keep it in `unaccessed_pure_accounts` so a + // BAL that carries a bare SYSTEM_ADDRESS entry without a corresponding user-tx + // touch is rejected as extraneous. for addr in system_seed.keys() { + if *addr == SYSTEM_ADDRESS { + continue; + } unaccessed_pure_accounts.remove(addr); } @@ -950,7 +1071,9 @@ impl LEVM { ExecutionReport, FxHashMap, FxHashMap, - FxHashSet
, // accessed_accounts tracker + FxHashSet
, // accessed_accounts tracker (coarse) + Vec
, // shadow recorder touched_addresses (EIP-7928 exact) + Vec<(Address, U256)>, // shadow recorder storage_reads (EIP-7928 exact) ); let exec_results: Result, EvmError> = (0..n_txs) @@ -970,19 +1093,33 @@ impl LEVM { // BAL index: 0 = system calls, 1 = tx 0, 2 = tx 1, ... // For tx at index i, we want state through BAL index i // (= system calls + effects of txs 0..i-1). - #[allow(clippy::cast_possible_truncation)] Self::seed_db_from_bal( &mut tx_db, bal, - tx_idx as u16, + u32::try_from(tx_idx).unwrap_or(u32::MAX), &validation_index.accounts_by_min_index, )?; - // Enable accessed_accounts tracker for BAL pure-access validation. - // Most txs touch sender + recipient + a few contracts; 16 avoids rehashing. + // Enable accessed_accounts tracker (coarse) for `unaccessed_pure_accounts` + // diagnostics. Safe to over-report: used only to REMOVE entries from a + // extraneous-entry checklist. tx_db.accessed_accounts = Some(FxHashSet::with_capacity_and_hasher(16, Default::default())); + // Enable a shadow BAL recorder on this per-tx db. The recorder is gated + // at the same gas-check points as the builder path, giving us an exact + // EIP-7928 access signal (missing-account and missing-storage-read + // detection). Per-tx recorder โ€” no cross-task contention. + tx_db.enable_bal_recording(); + let bal_index = u32::try_from(tx_idx + 1).unwrap_or(u32::MAX); + tx_db.set_bal_index(bal_index); + if let Some(recorder) = tx_db.bal_recorder_mut() { + recorder.record_touched_address(*sender); + if let TxKind::Call(to) = tx.to() { + recorder.record_touched_address(to); + } + } + let report = LEVM::execute_tx_in_block( tx, *sender, @@ -997,22 +1134,54 @@ impl LEVM { let current_state = std::mem::take(&mut tx_db.current_accounts_state); let codes = std::mem::take(&mut tx_db.codes); let tracked = tx_db.accessed_accounts.take().unwrap_or_default(); - Ok((tx_idx, tx.tx_type(), report, current_state, codes, tracked)) + let (shadow_touched, shadow_reads) = tx_db + .bal_recorder + .take() + .map(|mut r| (r.take_touched_addresses(), r.take_storage_reads())) + .unwrap_or_default(); + Ok(( + tx_idx, + tx.tx_type(), + report, + current_state, + codes, + tracked, + shadow_touched, + shadow_reads, + )) }) .collect(); let mut exec_results = exec_results?; // Sort so gas accounting and validation happen in tx order. - exec_results.sort_unstable_by_key(|(idx, _, _, _, _, _)| *idx); + exec_results.sort_unstable_by_key(|(idx, _, _, _, _, _, _, _)| *idx); // 3. Gas limit check โ€” must happen BEFORE BAL validation so that blocks // exceeding the gas limit produce GAS_USED_OVERFLOW instead of a BAL // mismatch error (the BAL is built assuming rejected txs, so the miner // balance in the BAL won't match execution that ran all txs). + // + // EIP-8037 PR #2703: also enforce the per-tx 2D inclusion check + // against running block totals. A tx whose worst-case regular or + // state contribution exceeds the remaining budget at its inclusion + // position invalidates the block with GAS_ALLOWANCE_EXCEEDED. let mut block_regular_gas_used = 0_u64; let mut block_state_gas_used = 0_u64; - for (_, _, report, _, _, _) in &exec_results { + for (tx_idx, _, report, _, _, _, _, _) in &exec_results { + let (tx, _) = txs_with_sender + .get(*tx_idx) + .ok_or_else(|| EvmError::Custom(format!("tx index {tx_idx} out of bounds")))?; + if is_amsterdam { + check_2d_gas_allowance( + tx, + Fork::Amsterdam, + block_regular_gas_used, + block_state_gas_used, + header.gas_limit, + )?; + } + let tx_state_gas = report.state_gas_used; let tx_regular_gas = report.gas_used.saturating_sub(tx_state_gas); block_regular_gas_used = block_regular_gas_used.saturating_add(tx_regular_gas); @@ -1030,11 +1199,11 @@ impl LEVM { // 4. Per-tx BAL validation โ€” now safe to run after gas limit is confirmed OK. // Also mark off storage_reads that appear in per-tx execution state. - for (tx_idx, _, _, current_state, codes, tracked_accounts) in &exec_results { - #[allow(clippy::cast_possible_truncation)] - let bal_idx = (*tx_idx + 1) as u16; - #[allow(clippy::cast_possible_truncation)] - let seed_idx = *tx_idx as u16; + for (tx_idx, _, _, current_state, codes, tracked_accounts, shadow_touched, shadow_reads) in + &exec_results + { + let bal_idx = u32::try_from(*tx_idx + 1).unwrap_or(u32::MAX); + let seed_idx = u32::try_from(*tx_idx).unwrap_or(u32::MAX); Self::validate_tx_execution( bal_idx, seed_idx, @@ -1079,12 +1248,44 @@ impl LEVM { unaccessed_pure_accounts.remove(addr); } } + + // EIP-7928 (Group B): missing-access detection using the shadow recorder. + // For each address the per-tx shadow recorder marked as touched, the header + // BAL must contain an entry for it. For each storage read, the header BAL + // must carry the slot either in storage_changes or storage_reads. + for addr in shadow_touched { + if !validation_index.addr_to_idx.contains_key(addr) { + return Err(EvmError::Custom(format!( + "BAL validation failed for tx {tx_idx}: account {addr:?} was \ + accessed during execution but is missing from BAL" + ))); + } + } + for (addr, slot) in shadow_reads { + let Some(&bal_acct_idx) = validation_index.addr_to_idx.get(addr) else { + // Already caught by the touched-address check above. + continue; + }; + let acct = &bal.accounts()[bal_acct_idx]; + let in_changes = acct + .storage_changes + .binary_search_by(|sc| sc.slot.cmp(slot)) + .is_ok(); + let in_reads = acct.storage_reads.contains(slot); + if !in_changes && !in_reads { + return Err(EvmError::Custom(format!( + "BAL validation failed for tx {tx_idx}: storage slot {slot} of \ + account {addr:?} was read during execution but is missing from \ + BAL (no storage_changes or storage_reads entry)" + ))); + } + } } // 5. Build receipts in tx order. let mut receipts = Vec::with_capacity(n_txs); let mut cumulative_gas_used = 0_u64; - for (_, tx_type, report, _, _, _) in exec_results { + for (_, tx_type, report, _, _, _, _, _) in exec_results { cumulative_gas_used += report.gas_spent; let receipt = Receipt::new( tx_type, @@ -1106,7 +1307,7 @@ impl LEVM { /// Gets the seeded balance for an account at `seed_idx` from BAL, falling /// back to system_seed/store if no BAL entry exists before that index. fn seeded_balance( - seed_idx: u16, + seed_idx: u32, acct: ðrex_common::types::block_access_list::AccountChanges, system_seed: &CacheDB, store: &Arc, @@ -1134,7 +1335,7 @@ impl LEVM { /// Gets the seeded nonce for an account at `seed_idx` from BAL, falling /// back to system_seed/store if no BAL entry exists before that index. fn seeded_nonce( - seed_idx: u16, + seed_idx: u32, acct: ðrex_common::types::block_access_list::AccountChanges, system_seed: &CacheDB, store: &Arc, @@ -1176,8 +1377,8 @@ impl LEVM { /// `store`: database (fallback for pre-state lookups) #[allow(clippy::too_many_arguments)] fn validate_tx_execution( - bal_idx: u16, - seed_idx: u16, + bal_idx: u32, + seed_idx: u32, current_state: &FxHashMap, codes: &FxHashMap, bal: &BlockAccessList, @@ -1211,17 +1412,17 @@ impl LEVM { let seeded = Self::seeded_balance(seed_idx, acct, system_seed, store)?; if expected != seeded { // Dump full BAL entry for diagnosis - let all_bal_indices: Vec = acct + let all_bal_indices: Vec = acct .balance_changes .iter() .map(|c| c.block_access_index) .collect(); - let all_nonce_indices: Vec = acct + let all_nonce_indices: Vec = acct .nonce_changes .iter() .map(|c| c.block_access_index) .collect(); - let all_storage_indices: Vec<(u16, u64)> = acct + let all_storage_indices: Vec<(u32, u64)> = acct .storage_changes .iter() .flat_map(|sc| { @@ -1230,7 +1431,7 @@ impl LEVM { .map(|c| (c.block_access_index, sc.slot.low_u64())) }) .collect(); - let code_indices: Vec = acct + let code_indices: Vec = acct .code_changes .iter() .map(|c| c.block_access_index) @@ -1459,19 +1660,30 @@ impl LEVM { let seeded_pos = acct .code_changes .partition_point(|c| c.block_access_index <= seed_idx); - if seeded_pos > 0 { + let seeded_hash = if seeded_pos > 0 { let seeded_code = &acct.code_changes[seeded_pos - 1].new_code; - let seeded_hash = if seeded_code.is_empty() { + if seeded_code.is_empty() { *EMPTY_KECCACK_HASH } else { ethrex_common::utils::keccak(seeded_code) - }; - if account.info.code_hash != seeded_hash { - return Err(BalValidationError::Mismatch(format!( - "account {addr:?} code changed by execution but BAL has no \ - code change at index {bal_idx}" - ))); } + } else { + // No BAL code entry before this tx โ€” value came from system_seed or store. + system_seed + .get(addr) + .map(|a| a.info.code_hash) + .unwrap_or_else(|| { + store + .get_account_state(*addr) + .map(|a| a.code_hash) + .unwrap_or(*EMPTY_KECCACK_HASH) + }) + }; + if account.info.code_hash != seeded_hash { + return Err(BalValidationError::Mismatch(format!( + "account {addr:?} code changed by execution but BAL has no \ + code change at index {bal_idx} (seeded_hash={seeded_hash:?})" + ))); } } @@ -1522,7 +1734,7 @@ impl LEVM { fn validate_bal_withdrawal_index( db: &GeneralizedDatabase, bal: &BlockAccessList, - withdrawal_idx: u16, + withdrawal_idx: u32, index: &BalAddressIndex, ) -> Result<(), EvmError> { // Part A: For each BAL account with changes at the withdrawal index, @@ -2013,6 +2225,7 @@ impl LEVM { is_privileged: matches!(tx, Transaction::PrivilegedL2Transaction(_)), fee_token: tx.fee_token(), disable_balance_check: false, + is_system_call: false, }; Ok(env) @@ -2326,7 +2539,12 @@ pub fn generic_system_contract_levm( gas_price: U256::zero(), block_excess_blob_gas: block_header.excess_blob_gas, block_blob_gas_used: block_header.blob_gas_used, - block_gas_limit: i64::MAX as u64, // System calls, have no constraint on the block's gas limit. + // Use the actual block's gas_limit so EIP-8037 cost_per_state_byte is correct. + // The gas-allowance check is bypassed via `is_system_call` below; feeding + // i64::MAX here would make cpsb astronomically large and OOG any SSTORE + // that charges state gas (e.g. EIP-2935, EIP-4788 new-slot writes). + block_gas_limit: block_header.gas_limit, + is_system_call: true, config, ..Default::default() }; @@ -2556,6 +2774,7 @@ fn env_from_generic( is_privileged: false, fee_token: tx.fee_token, disable_balance_check: false, + is_system_call: false, }) } diff --git a/crates/vm/backends/mod.rs b/crates/vm/backends/mod.rs index 47c16578176..e85811b843e 100644 --- a/crates/vm/backends/mod.rs +++ b/crates/vm/backends/mod.rs @@ -226,8 +226,8 @@ impl Evm { self.db.enable_bal_recording(); } - /// Sets the current block access index for BAL recording per EIP-7928 spec (uint16). - pub fn set_bal_index(&mut self, index: u16) { + /// Sets the current block access index for BAL recording per EIP-7928 spec (uint32). + pub fn set_bal_index(&mut self, index: u32) { self.db.set_bal_index(index); } diff --git a/crates/vm/levm/src/call_frame.rs b/crates/vm/levm/src/call_frame.rs index 5468bd3171e..4c61472cd75 100644 --- a/crates/vm/levm/src/call_frame.rs +++ b/crates/vm/levm/src/call_frame.rs @@ -289,6 +289,42 @@ pub struct CallFrame { pub should_transfer_value: bool, /// EIP-8037: snapshot of VM.state_gas_used at the start of this frame (for revert restoration) pub state_gas_used_snapshot: u64, + /// EIP-8037 clamp-and-spill: amount of state gas that has been credited back to this frame. + /// Used to compute the unrefunded local charge when clamping a refund against this frame. + pub state_gas_refund: u64, + /// EIP-8037 clamp-and-spill: snapshot of VM.state_gas_refund_pending at the start of this + /// frame. Restored on revert so reverted children don't contribute pending refunds. + pub state_gas_refund_pending_snapshot: u64, + /// EIP-8037 clamp-and-spill: snapshot of VM.state_gas_refund_absorbed at the start of this + /// frame. Restored on revert so reverted children don't contribute absorbed refunds. + pub state_gas_refund_absorbed_snapshot: u64, + /// EIP-8037: snapshot of VM.state_gas_reservoir at the start of this frame. Restored on + /// revert so mid-child charges and refund refills are both undone atomically. + pub state_gas_reservoir_snapshot: u64, + /// EIP-8037: snapshot of VM.state_gas_spill_outstanding at the start of this frame. + /// Used both to compute the frame's own outstanding delta (for the revert-side + /// reservoir math) and as the baseline for `credit_state_gas_refund`'s + /// `applied_to_spill = min(clamped, frame_outstanding_delta)` clamp. + pub state_gas_spill_outstanding_snapshot: u64, + /// EIP-8037: snapshot of VM.state_gas_credit_against_drain at the start of this frame. + /// Restored on revert so reverted children don't leak drain-credits into the + /// reservoir math at a grandparent boundary. + pub state_gas_credit_against_drain_snapshot: u64, + /// EIP-8037 PR #2689: snapshot of VM.state_gas_spill (gross monotonic) at + /// frame entry. Used by handle_return_call's halt branch to compute the + /// `credit_cancelled_spill` for reclassification. Spill that was credited + /// away (via `credit_state_gas_refund`'s `applied_to_spill` decrement of + /// `state_gas_spill_outstanding`) was permanently consumed from + /// gas_remaining but is no longer in spill_outstanding, so default_hook's + /// `regular_gas = raw - state_gas_spill + reclassified` permanently + /// excludes it from regular dim. Reclassify it here so block.gasUsed + /// matches EELS' tx_output.regular_gas_used. + pub state_gas_spill_snapshot: u64, + /// EIP-8037 PR #2689: snapshot of VM.regular_gas_reclassified at frame + /// entry. Used by handle_return_call's halt branch to avoid double-counting + /// credit-cancelled spill that was already reclassified at deeper halt + /// boundaries within this subtree. + pub regular_gas_reclassified_snapshot: u64, } #[derive(Debug, Clone, Eq, PartialEq, Default)] @@ -394,6 +430,14 @@ impl CallFrame { pc: 0, sub_return_data: Bytes::default(), state_gas_used_snapshot: 0, + state_gas_refund: 0, + state_gas_refund_pending_snapshot: 0, + state_gas_refund_absorbed_snapshot: 0, + state_gas_reservoir_snapshot: 0, + state_gas_spill_outstanding_snapshot: 0, + state_gas_credit_against_drain_snapshot: 0, + state_gas_spill_snapshot: 0, + regular_gas_reclassified_snapshot: 0, } } diff --git a/crates/vm/levm/src/db/gen_db.rs b/crates/vm/levm/src/db/gen_db.rs index 9cf4458246f..f94036a93ef 100644 --- a/crates/vm/levm/src/db/gen_db.rs +++ b/crates/vm/levm/src/db/gen_db.rs @@ -106,9 +106,9 @@ impl GeneralizedDatabase { self.bal_recorder = None; } - /// Sets the current block access index for BAL recording per EIP-7928 spec (uint16). + /// Sets the current block access index for BAL recording per EIP-7928 spec (uint32). /// Call this before each transaction or phase. - pub fn set_bal_index(&mut self, index: u16) { + pub fn set_bal_index(&mut self, index: u32) { if let Some(recorder) = &mut self.bal_recorder { recorder.set_block_access_index(index); } diff --git a/crates/vm/levm/src/environment.rs b/crates/vm/levm/src/environment.rs index 441a998a652..e447499bc68 100644 --- a/crates/vm/levm/src/environment.rs +++ b/crates/vm/levm/src/environment.rs @@ -44,6 +44,10 @@ pub struct Environment { /// When true, skip balance deduction in `deduct_caller`. Used by the prewarmer /// to avoid early reverts on insufficient balance so that warming touches more storage. pub disable_balance_check: bool, + /// When true, the tx is a pre-execution system contract call (EIP-2935, EIP-4788, + /// EIP-7002, EIP-7251 etc.). Skips the block-level gas-allowance check since system + /// calls are allowed to exceed `block_gas_limit` (their 30M cap is a separate rule). + pub is_system_call: bool, } /// This struct holds special configuration variables specific to the diff --git a/crates/vm/levm/src/errors.rs b/crates/vm/levm/src/errors.rs index 4386848b0c2..28d59ff9651 100644 --- a/crates/vm/levm/src/errors.rs +++ b/crates/vm/levm/src/errors.rs @@ -281,4 +281,10 @@ impl ContextResult { )) ) } + + /// True if the failure was caused by the REVERT opcode (intentional revert). + /// PR #2689 reclassification only applies to ExceptionalHalt, not REVERT. + pub fn is_revert_opcode(&self) -> bool { + matches!(self.result, TxResult::Revert(VMError::RevertOpcode)) + } } diff --git a/crates/vm/levm/src/execution_handlers.rs b/crates/vm/levm/src/execution_handlers.rs index cd6b2c8a789..b9b4f2626ae 100644 --- a/crates/vm/levm/src/execution_handlers.rs +++ b/crates/vm/levm/src/execution_handlers.rs @@ -1,7 +1,7 @@ use crate::{ constants::*, errors::{ContextResult, ExceptionalHalt, InternalError, TxResult, VMError}, - gas_cost::{CODE_DEPOSIT_COST, CODE_DEPOSIT_REGULAR_COST_PER_WORD, COST_PER_STATE_BYTE}, + gas_cost::{CODE_DEPOSIT_COST, CODE_DEPOSIT_REGULAR_COST_PER_WORD}, utils::create_eth_transfer_log, vm::VM, }; @@ -184,7 +184,7 @@ impl<'a> VM<'a> { .checked_mul(CODE_DEPOSIT_REGULAR_COST_PER_WORD) .ok_or(InternalError::Overflow)?; let state = code_length - .checked_mul(COST_PER_STATE_BYTE) + .checked_mul(self.cost_per_state_byte) .ok_or(InternalError::Overflow)?; // Regular gas (keccak hash cost) before state gas diff --git a/crates/vm/levm/src/gas_cost.rs b/crates/vm/levm/src/gas_cost.rs index 6ca5440ec76..3a52fb6d8ad 100644 --- a/crates/vm/levm/src/gas_cost.rs +++ b/crates/vm/levm/src/gas_cost.rs @@ -7,7 +7,7 @@ use crate::{ use ExceptionalHalt::OutOfGas; use bytes::Bytes; /// Contains the gas costs of the EVM instructions -use ethrex_common::{U256, types::Fork}; +use ethrex_common::{U256, types::Fork, types::tx_fields::AccessList}; use malachite::base::num::logic::traits::*; use malachite::{Natural, base::num::basic::traits::Zero as _}; @@ -162,14 +162,17 @@ pub const CODE_DEPOSIT_COST: u64 = 200; pub const CREATE_BASE_COST: u64 = 32000; // EIP-8037: Multidimensional gas for state creation (Amsterdam only) -pub const COST_PER_STATE_BYTE: u64 = 1174; pub const STATE_BYTES_PER_NEW_ACCOUNT: u64 = 112; pub const STATE_BYTES_PER_STORAGE_SET: u64 = 32; pub const STATE_BYTES_PER_AUTH_TOTAL: u64 = 135; // 112 account + 23 auth-specific -// Pre-computed products to avoid repeated checked_mul in hot paths -pub const STATE_GAS_NEW_ACCOUNT: u64 = STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE; // 131_488 -pub const STATE_GAS_STORAGE_SET: u64 = STATE_BYTES_PER_STORAGE_SET * COST_PER_STATE_BYTE; // 37_568 -pub const STATE_GAS_AUTH_TOTAL: u64 = STATE_BYTES_PER_AUTH_TOTAL * COST_PER_STATE_BYTE; // 158_490 + +/// EIP-8037 cost_per_state_byte. Pinned to the bal-devnet-4..6 fixed value 1174 +/// (execution-specs#2687). The dynamic formula derived from the block gas limit +/// is not active on devnet-6. +pub fn cost_per_state_byte(_block_gas_limit: u64) -> u64 { + 1174 +} + pub const REGULAR_GAS_CREATE: u64 = 9000; // replaces CREATE_BASE_COST for Amsterdam pub const CODE_DEPOSIT_REGULAR_COST_PER_WORD: u64 = 6; // keccak hash cost per 32-byte word @@ -197,6 +200,18 @@ pub const P256_VERIFY_COST: u64 = 6900; // Floor cost per token, specified in https://eips.ethereum.org/EIPS/eip-7623 pub const TOTAL_COST_FLOOR_PER_TOKEN: u64 = 10; +// EIP-7976 (Amsterdam+): raised floor +pub const TOTAL_COST_FLOOR_PER_TOKEN_AMSTERDAM: u64 = 16; + +/// Returns the floor cost per token for the given fork. +/// EIP-7976 raises this from 10 (EIP-7623) to 16 starting at Amsterdam. +pub fn total_cost_floor_per_token(fork: Fork) -> u64 { + if fork >= Fork::Amsterdam { + TOTAL_COST_FLOOR_PER_TOKEN_AMSTERDAM + } else { + TOTAL_COST_FLOOR_PER_TOKEN + } +} pub const SHA2_256_STATIC_COST: u64 = 60; pub const SHA2_256_DYNAMIC_BASE: u64 = 12; @@ -430,7 +445,7 @@ pub fn sstore( } else if current_value == original_value { if original_value.is_zero() { // For Amsterdam+, new slot creation uses MODIFICATION cost in regular gas; - // the state cost (32 * COST_PER_STATE_BYTE) is charged separately. + // the state cost (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte) is charged separately. if fork >= Fork::Amsterdam { SSTORE_STORAGE_MODIFICATION } else { @@ -617,6 +632,24 @@ pub fn tx_calldata(calldata: &Bytes) -> Result { Ok(calldata_cost) } +/// Returns the total byte-size of an access list: +/// 20 bytes per address entry + 32 bytes per storage key. +pub fn access_list_bytes(access_list: &AccessList) -> u64 { + let mut bytes: u64 = 0; + for (_addr, keys) in access_list { + bytes = bytes.saturating_add(20); + let keys_len = u64::try_from(keys.len()).unwrap_or(u64::MAX); + bytes = bytes.saturating_add(32_u64.saturating_mul(keys_len)); + } + bytes +} + +/// EIP-7981: floor_tokens_in_access_list = access_list_bytes * STANDARD_TOKEN_COST (4). +/// Used in the floor-gas computation for Amsterdam+. +pub fn floor_tokens_in_access_list(access_list: &AccessList) -> u64 { + access_list_bytes(access_list).saturating_mul(STANDARD_TOKEN_COST) +} + fn address_access_cost( address_was_cold: bool, static_cost: u64, diff --git a/crates/vm/levm/src/hooks/default_hook.rs b/crates/vm/levm/src/hooks/default_hook.rs index 341c5c28df6..ea9c0eef3e7 100644 --- a/crates/vm/levm/src/hooks/default_hook.rs +++ b/crates/vm/levm/src/hooks/default_hook.rs @@ -2,7 +2,9 @@ use crate::{ account::LevmAccount, constants::*, errors::{ContextResult, ExceptionalHalt, InternalError, TxValidationError, VMError}, - gas_cost::{self, STANDARD_TOKEN_COST, TOTAL_COST_FLOOR_PER_TOKEN}, + gas_cost::{ + self, STANDARD_TOKEN_COST, floor_tokens_in_access_list, total_cost_floor_per_token, + }, hooks::hook::Hook, utils::*, vm::VM, @@ -148,11 +150,11 @@ impl Hook for DefaultHook { // intrinsic gas (no execution gas was consumed). if vm.env.config.fork >= Fork::Amsterdam && ctx_result.is_collision() { let gas_limit = vm.env.gas_limit; - // Block accounting: gas_used = intrinsic_regular + intrinsic_state - // state_gas_used already = intrinsic_state (no execution state gas) - let state_gas = vm - .state_gas_used - .saturating_sub(vm.intrinsic_state_gas_refund); + // Block accounting: gas_used = intrinsic_regular + intrinsic_state. + // state_gas_used already = intrinsic_state (no execution state gas). + // Per EELS, `tx_env.intrinsic_state_gas` is immutable โ€” any auth refund + // goes to the reservoir, not to block-accounted state_gas. + let state_gas = vm.state_gas_used; let floor = vm.get_min_gas_used()?; // Regular gas from intrinsic only (gas_limit - reservoir - gas_remaining at collision) // = total_intrinsic_gas consumed so far, minus state portion @@ -177,6 +179,14 @@ impl Hook for DefaultHook { return Ok(()); } + // EIP-8037 PR #2707: on tx success, refund state gas for same-tx + // created accounts that were SELFDESTRUCTed โ€” NEW_ACCOUNT + SSTORE + // state gas for created slots + code_length * cpsb. Must run BEFORE + // the reservoir subtraction so sender gets the refund. + if vm.env.config.fork >= Fork::Amsterdam && ctx_result.is_success() { + apply_same_tx_selfdestruct_state_refund(vm)?; + } + // EIP-8037 (Amsterdam+): unused reservoir is always returned to sender. // Per EELS, state_gas_left is preserved even on exceptional halt โ€” only // regular gas_left is burned. The user does NOT pay for unspent reservoir. @@ -194,7 +204,7 @@ impl Hook for DefaultHook { let gas_refunded: u64 = compute_gas_refunded(vm, ctx_result)?; let gas_spent = compute_actual_gas_used(vm, gas_refunded, gas_used_pre_refund)?; - refund_sender(vm, ctx_result, gas_refunded, gas_spent, gas_used_pre_refund)?; + refund_sender(vm, ctx_result, gas_refunded, gas_spent)?; pay_coinbase(vm, gas_spent)?; @@ -215,20 +225,14 @@ pub fn undo_value_transfer(vm: &mut VM<'_>) -> Result<(), VMError> { Ok(()) } -/// Refunds unused gas to the sender. -/// -/// # EIP-7778 Changes -/// - `gas_spent`: Post-refund gas (what the user actually pays) -/// - `gas_used_pre_refund`: Pre-refund gas (for block-level accounting in Amsterdam+) -/// -/// For Amsterdam+, the block uses pre-refund gas (`gas_used`) while the user pays post-refund -/// gas (`gas_spent`). Before Amsterdam, both values are the same (post-refund). +/// Refunds unused gas to the sender. The user pays `gas_spent` (post-refund); +/// for Amsterdam+, block-level accounting is recomputed dimensionally from VM +/// fields, not from a pre-refund total. pub fn refund_sender( vm: &mut VM<'_>, ctx_result: &mut ContextResult, refunded_gas: u64, gas_spent: u64, - gas_used_pre_refund: u64, ) -> Result<(), VMError> { vm.substate.refunded_gas = refunded_gas; @@ -238,18 +242,35 @@ pub fn refund_sender( if vm.env.config.fork >= Fork::Amsterdam { // EIP-7623 floor applies to the regular (non-state) gas component only. let floor = vm.get_min_gas_used()?; - // Apply intrinsic state gas refund from existing authorities (EIP-7702/EIP-8037). - // This matches EELS where set_delegation permanently reduces tx_env.intrinsic_state_gas - // for existing authorities, regardless of execution outcome. - let state_gas = vm - .state_gas_used - .saturating_sub(vm.intrinsic_state_gas_refund); - // State gas from reverted children is added back to the reservoir - // (matching EELS incorporate_child_on_error), so gas_used_pre_refund - // already excludes it after the reservoir subtraction at line 184. - // EIP-8037 (bal@v5.4.0): regular_gas = total gas - state gas. - // Collision-burned gas counts as regular gas for 2D block accounting. - let regular_gas = gas_used_pre_refund.saturating_sub(state_gas); + // EELS block accounting per fork.py: + // tx_regular_gas = intrinsic_regular + regular_gas_used + // tx_state_gas = intrinsic_state + state_gas_used (net after refunds) + // Reservoir activity (auth refunds, SSTORE 0โ†’Nโ†’0 credits) is NEUTRAL to + // block accounting โ€” it only affects sender refund. To derive tx_regular_gas + // from our raw gas consumption, subtract intrinsic_state, the initial + // reservoir (pre-consumed from gas_remaining in add_intrinsic_gas), and any + // state-gas spills that reduced gas_remaining (EELS charge_state_gas spills + // don't count as regular_gas_used). + let execution_state_gas_refund = vm + .state_gas_refund_absorbed + .saturating_add(vm.state_gas_refund_pending); + let state_gas = vm.state_gas_used.saturating_sub(execution_state_gas_refund); + // Compute raw consumption from scratch (gas_limit minus gas_remaining) + // to avoid interference from any reservoir-current subtraction baked + // into the caller's pre-refund number. + #[expect(clippy::as_conversions, reason = "gas_remaining is >= 0 here")] + let gas_remaining = vm.current_call_frame.gas_remaining.max(0) as u64; + let raw_consumed = vm.env.gas_limit.saturating_sub(gas_remaining); + // PR #2689: state-gas charges that were halted (top-level or sub-frame) get + // reclassified to regular_gas_used via `regular_gas_reclassified`. The base + // formula subtracts every spill (treats them all as state-gas); the + // reclassification term adds back the halted portion so it counts toward the + // regular dimension. + let regular_gas = raw_consumed + .saturating_sub(vm.intrinsic_state_gas_charged) + .saturating_sub(vm.state_gas_reservoir_initial) + .saturating_sub(vm.state_gas_spill) + .saturating_add(vm.regular_gas_reclassified); let effective_regular = regular_gas.max(floor); ctx_result.gas_used = effective_regular .checked_add(state_gas) @@ -333,6 +354,77 @@ pub fn pay_coinbase(vm: &mut VM<'_>, gas_to_pay: u64) -> Result<(), VMError> { Ok(()) } +/// EIP-8037 PR #2707: same-tx SELFDESTRUCT refunds state gas to the reservoir. +/// +/// For each SELFDESTRUCTed address that was CREATEd in the same transaction, refund: +/// - STATE_BYTES_PER_NEW_ACCOUNT * cpsb (account creation) +/// - STATE_BYTES_PER_STORAGE_SET * cpsb per non-zero storage slot written in this tx +/// - code_length * cpsb (the deployed code) +/// +/// Refund is clamped to the net execution state_gas_used (gross minus already-absorbed +/// and pending credits) so it cannot go negative. Adds to both the reservoir (so the +/// sender gets it back via the reservoir subtraction in `finalize_execution`) and to +/// `state_gas_refund_absorbed` (so block-accounted `state_gas` is reduced accordingly). +pub fn apply_same_tx_selfdestruct_state_refund(vm: &mut VM<'_>) -> Result<(), VMError> { + let cpsb = vm.cost_per_state_byte; + let new_account_bytes = crate::gas_cost::STATE_BYTES_PER_NEW_ACCOUNT; + let storage_set_bytes = crate::gas_cost::STATE_BYTES_PER_STORAGE_SET; + + // Collect (address, refund_amount) first to avoid borrow conflicts with db access. + let mut refunds: Vec = Vec::new(); + let selfdestruct_addrs: Vec
= vm.substate.iter_selfdestruct().copied().collect(); + for addr in &selfdestruct_addrs { + if !vm.substate.is_account_created(addr) { + continue; + } + let account = vm.db.get_account(*addr)?; + let created_slots: u64 = account + .storage + .values() + .filter(|v| !v.is_zero()) + .count() + .try_into() + .unwrap_or(u64::MAX); + let code_hash = account.info.code_hash; + let code = vm.db.get_code(code_hash)?.clone(); + let code_len: u64 = u64::try_from(code.bytecode.len()).unwrap_or(u64::MAX); + + let per_byte: u64 = new_account_bytes + .saturating_add(created_slots.saturating_mul(storage_set_bytes)) + .saturating_add(code_len); + let refund = per_byte.saturating_mul(cpsb); + refunds.push(refund); + } + + for refund in refunds { + // EELS fork.py:1100 clamps against `tx_output.state_gas_used`, which is the + // execution-only accumulator (intrinsic lives separately in tx_env.intrinsic_state_gas). + // Our `vm.state_gas_used` lumps intrinsic + execution, so subtract the intrinsic + // portion here โ€” otherwise a CREATE tx whose initcode SELFDESTRUCTs would refund + // its own intrinsic NEW_ACCOUNT charge. + let execution_state_gas = vm + .state_gas_used + .saturating_sub(vm.intrinsic_state_gas_charged); + let net_state_gas = execution_state_gas + .saturating_sub(vm.state_gas_refund_absorbed) + .saturating_sub(vm.state_gas_refund_pending); + let clamped = refund.min(net_state_gas); + if clamped == 0 { + continue; + } + vm.state_gas_reservoir = vm + .state_gas_reservoir + .checked_add(clamped) + .ok_or(InternalError::Overflow)?; + vm.state_gas_refund_absorbed = vm + .state_gas_refund_absorbed + .checked_add(clamped) + .ok_or(InternalError::Overflow)?; + } + + Ok(()) +} + // In Cancun the only addresses destroyed are contracts created in this transaction pub fn delete_self_destruct_accounts(vm: &mut VM<'_>) -> Result<(), VMError> { // EIP-7708: Emit Burn logs for accounts with non-zero balance marked for deletion @@ -391,15 +483,39 @@ pub fn validate_min_gas_limit(vm: &mut VM<'_>) -> Result<(), VMError> { return Err(TxValidationError::IntrinsicGasTooLow.into()); } - // calldata_cost = tokens_in_calldata * 4 - let calldata_cost: u64 = gas_cost::tx_calldata(&calldata)?; + let fork = vm.env.config.fork; + + // EIP-7976 floor tokens: for the floor arm, all calldata bytes count unweighted. + // floor_tokens_in_calldata = (zero_bytes + nonzero_bytes) * STANDARD_TOKEN_COST + // Pre-Amsterdam uses the weighted EIP-7623 formula: (nonzero * 16 + zero * 4) / 4 + let mut tokens_in_calldata: u64 = if fork >= Fork::Amsterdam { + // EIP-7976: floor tokens = total_bytes * STANDARD_TOKEN_COST (unweighted). + let total_bytes: u64 = calldata + .len() + .try_into() + .map_err(|_| InternalError::TypeConversion)?; + total_bytes + .checked_mul(STANDARD_TOKEN_COST) + .ok_or(InternalError::Overflow)? + } else { + // Pre-Amsterdam: weighted EIP-7623 token count. + gas_cost::tx_calldata(&calldata)? / STANDARD_TOKEN_COST + }; - // same as calculated in gas_used() - let tokens_in_calldata: u64 = calldata_cost / STANDARD_TOKEN_COST; + // EIP-7981 (Amsterdam+): access-list data bytes fold into the floor-token count. + // floor_tokens_in_access_list = access_list_bytes * STANDARD_TOKEN_COST + // where access_list_bytes = 20 * address_count + 32 * storage_key_count. + if fork >= Fork::Amsterdam { + let al_floor_tokens = floor_tokens_in_access_list(vm.tx.access_list()); + tokens_in_calldata = tokens_in_calldata + .checked_add(al_floor_tokens) + .ok_or(InternalError::Overflow)?; + } - // floor_cost_by_tokens = TX_BASE_COST + TOTAL_COST_FLOOR_PER_TOKEN * tokens_in_calldata + // floor_cost_by_tokens = TX_BASE_COST + total_cost_floor_per_token(fork) * tokens + // EIP-7976 (Amsterdam+) raises the floor multiplier from 10 to 16. let floor_cost_by_tokens = tokens_in_calldata - .checked_mul(TOTAL_COST_FLOOR_PER_TOKEN) + .checked_mul(total_cost_floor_per_token(fork)) .ok_or(InternalError::Overflow)? .checked_add(TX_BASE_COST) .ok_or(InternalError::Overflow)?; @@ -567,6 +683,12 @@ pub fn validate_sender(sender_address: Address, code: &Bytes) -> Result<(), VMEr } pub fn validate_gas_allowance(vm: &mut VM<'_>) -> Result<(), TxValidationError> { + // System contract calls (EIP-2935, EIP-4788, EIP-7002, EIP-7251) bypass the + // block-level gas-allowance check โ€” their 30M gas budget is a protocol rule + // independent of `block_gas_limit`. + if vm.env.is_system_call { + return Ok(()); + } if vm.env.gas_limit > vm.env.block_gas_limit { return Err(TxValidationError::GasAllowanceExceeded { block_gas_limit: vm.env.block_gas_limit, diff --git a/crates/vm/levm/src/hooks/l2_hook.rs b/crates/vm/levm/src/hooks/l2_hook.rs index 57541261b95..736a00996e2 100644 --- a/crates/vm/levm/src/hooks/l2_hook.rs +++ b/crates/vm/levm/src/hooks/l2_hook.rs @@ -245,13 +245,7 @@ fn apply_finalize_mutations( fee_token_ratio, )?; } else { - default_hook::refund_sender( - vm, - ctx_result, - gas_refunded, - actual_gas_used, - total_gas_pre_refund, - )?; + default_hook::refund_sender(vm, ctx_result, gas_refunded, actual_gas_used)?; } pay_coinbase_l2( diff --git a/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs b/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs index 7cf1fa89d03..7f686f6b2ee 100644 --- a/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs +++ b/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs @@ -20,7 +20,7 @@ use crate::{ constants::WORD_SIZE_IN_BYTES_USIZE, errors::{ExceptionalHalt, InternalError, OpcodeResult, VMError}, - gas_cost::{self, SSTORE_STIPEND, STATE_GAS_STORAGE_SET}, + gas_cost::{self, SSTORE_STIPEND}, memory::calculate_memory_size, opcode_handlers::OpcodeHandler, opcodes::Opcode, @@ -303,8 +303,17 @@ impl OpcodeHandler for OpSStoreHandler { )?)?; if needs_state_gas { - vm.increase_state_gas(STATE_GAS_STORAGE_SET)?; + vm.increase_state_gas(vm.state_gas_storage_set)?; } + // EIP-8037 (Amsterdam+) 0โ†’Nโ†’0: the slot was created in this tx (original == 0), + // dirtied to N (current_value != 0), and now being reset to 0 (value == original == 0). + // The creation state gas is refunded via clamp-and-spill, not the regular refund counter. + let is_zero_to_n_to_zero_amsterdam = fork >= Fork::Amsterdam + && value != current_value + && current_value != original_value + && value == original_value + && original_value.is_zero(); + if value != current_value { // EIP-2929 const REMOVE_SLOT_COST: i64 = 4800; @@ -334,16 +343,10 @@ impl OpcodeHandler for OpSStoreHandler { if original_value.is_zero() { // EIP-8037 (Amsterdam+): restore_empty_slot_cost changes from 19900 to 2800 // because the SSTORE creation cost changed from 20000 to 2900. - // Also add state gas refund through the normal refund counter. + // The state gas portion is refunded via the reservoir (clamp-and-spill), + // NOT through the regular refund counter. if fork >= Fork::Amsterdam { delta += RESTORE_SLOT_COST; // 2800 instead of 19900 - #[expect( - clippy::as_conversions, - reason = "state gas constants fit i64" - )] - { - delta += STATE_GAS_STORAGE_SET as i64; - } } else { delta += RESTORE_EMPTY_SLOT_COST; } @@ -361,6 +364,11 @@ impl OpcodeHandler for OpSStoreHandler { } } + // EIP-8037: credit the state gas refund via clamp-and-spill (after regular gas processing). + if is_zero_to_n_to_zero_amsterdam { + vm.credit_state_gas_refund(vm.state_gas_storage_set)?; + } + if value != current_value { vm.update_account_storage(to, key, storage_slot_key, value, current_value)?; } diff --git a/crates/vm/levm/src/opcode_handlers/system.rs b/crates/vm/levm/src/opcode_handlers/system.rs index 70dd5faed6d..b085a3d6413 100644 --- a/crates/vm/levm/src/opcode_handlers/system.rs +++ b/crates/vm/levm/src/opcode_handlers/system.rs @@ -15,7 +15,7 @@ use crate::{ call_frame::CallFrame, constants::{AMSTERDAM_INIT_CODE_MAX_SIZE, FAIL, INIT_CODE_MAX_SIZE, SUCCESS}, errors::{ContextResult, ExceptionalHalt, InternalError, OpcodeResult, TxResult, VMError}, - gas_cost::{self, STATE_GAS_NEW_ACCOUNT}, + gas_cost, memory::{self, calculate_memory_size}, opcode_handlers::OpcodeHandler, precompiles, @@ -25,6 +25,7 @@ use crate::{ use bytes::Bytes; use ethrex_common::{Address, H256, U256, evm::calculate_create_address, types::Fork}; use ethrex_common::{tracing::CallType, types::Code}; +use std::mem; pub struct OpCallHandler; impl OpcodeHandler for OpCallHandler { @@ -95,13 +96,14 @@ impl OpcodeHandler for OpCallHandler { // reservoir on frame failure. let needs_state_gas = fork >= Fork::Amsterdam && address_is_empty && !value.is_zero(); let gas_left = if needs_state_gas { - let from_reservoir = vm.state_gas_reservoir.min(STATE_GAS_NEW_ACCOUNT); - // Safe: from_reservoir = min(reservoir, STATE_GAS_NEW_ACCOUNT) <= STATE_GAS_NEW_ACCOUNT + let state_gas_new_account = vm.state_gas_new_account; + let from_reservoir = vm.state_gas_reservoir.min(state_gas_new_account); + // Safe: from_reservoir = min(reservoir, state_gas_new_account) <= state_gas_new_account #[expect( clippy::arithmetic_side_effects, - reason = "from_reservoir <= STATE_GAS_NEW_ACCOUNT" + reason = "from_reservoir <= state_gas_new_account" )] - let spill = STATE_GAS_NEW_ACCOUNT - from_reservoir; + let spill = state_gas_new_account - from_reservoir; gas_left .checked_sub(spill) .ok_or(ExceptionalHalt::OutOfGas)? @@ -129,7 +131,7 @@ impl OpcodeHandler for OpCallHandler { // Then charge state gas for new account creation. if needs_state_gas { - vm.increase_state_gas(STATE_GAS_NEW_ACCOUNT)?; + vm.increase_state_gas(vm.state_gas_new_account)?; } // Resize memory: this is necessary for multiple reasons: @@ -567,7 +569,7 @@ impl OpcodeHandler for OpSelfDestructHandler { // EIP-8037 (Amsterdam+): charge state gas for new account creation via SELFDESTRUCT if target_account_is_empty && balance > U256::zero() { - vm.increase_state_gas(STATE_GAS_NEW_ACCOUNT)?; + vm.increase_state_gas(vm.state_gas_new_account)?; } } else { vm.current_call_frame @@ -691,7 +693,7 @@ impl<'a> VM<'a> { // EIP-8037 (Amsterdam+): charge state gas for new account creation AFTER // initcode size validation, so oversized CREATE doesn't burn state gas. if self.env.config.fork >= Fork::Amsterdam { - self.increase_state_gas(STATE_GAS_NEW_ACCOUNT)?; + self.increase_state_gas(self.state_gas_new_account)?; } let current_call_frame = &mut self.current_call_frame; @@ -753,6 +755,12 @@ impl<'a> VM<'a> { ]; for (condition, reason) in checks { if condition { + // EIP-8037: no account created on early failure โ€” refund the CREATE + // account state gas charged at the top of this function, per EELS + // `credit_state_gas_refund(evm, create_account_state_gas)`. + if self.env.config.fork >= Fork::Amsterdam { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } self.early_revert_message_call(gas_limit, reason.to_string())?; return Ok(OpcodeResult::Continue); } @@ -775,15 +783,14 @@ impl<'a> VM<'a> { // Deployment will fail (consuming all gas) if the contract already exists. let new_account = self.get_account_mut(new_address)?; if new_account.create_would_collide() { - // Per EELS: on collision, gas stays consumed (not returned) and - // the state gas reservoir is returned to the parent. - // In our model, the reservoir is shared and already at snapshot value. + // Per EELS: on collision, regular gas stays consumed (not returned) + // but the CREATE account state gas IS refunded โ€” no account was created. + if self.env.config.fork >= Fork::Amsterdam { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } self.current_call_frame.stack.push(FAIL)?; self.tracer .exit_early(gas_limit, Some("CreateAccExists".to_string()))?; - // EIP-8037 (bal@v5.4.0): Collision-burned gas counts as regular gas - // for 2D block gas accounting. The gas is already consumed (subtracted - // from gas_remaining), so it naturally appears in regular_gas_used. return Ok(OpcodeResult::Continue); } @@ -816,6 +823,14 @@ impl<'a> VM<'a> { // Store BAL checkpoint in the call frame's backup for restoration on revert new_call_frame.call_frame_backup.bal_checkpoint = bal_checkpoint; new_call_frame.state_gas_used_snapshot = create_state_gas_used_snapshot; + new_call_frame.state_gas_refund_pending_snapshot = self.state_gas_refund_pending; + new_call_frame.state_gas_refund_absorbed_snapshot = self.state_gas_refund_absorbed; + new_call_frame.state_gas_reservoir_snapshot = self.state_gas_reservoir; + new_call_frame.state_gas_spill_outstanding_snapshot = self.state_gas_spill_outstanding; + new_call_frame.state_gas_credit_against_drain_snapshot = + self.state_gas_credit_against_drain; + new_call_frame.state_gas_spill_snapshot = self.state_gas_spill; + new_call_frame.regular_gas_reclassified_snapshot = self.regular_gas_reclassified; self.add_callframe(new_call_frame); @@ -1031,6 +1046,14 @@ impl<'a> VM<'a> { // Store BAL checkpoint in the call frame's backup for restoration on revert new_call_frame.call_frame_backup.bal_checkpoint = bal_checkpoint; new_call_frame.state_gas_used_snapshot = self.state_gas_used; + new_call_frame.state_gas_refund_pending_snapshot = self.state_gas_refund_pending; + new_call_frame.state_gas_refund_absorbed_snapshot = self.state_gas_refund_absorbed; + new_call_frame.state_gas_reservoir_snapshot = self.state_gas_reservoir; + new_call_frame.state_gas_spill_outstanding_snapshot = self.state_gas_spill_outstanding; + new_call_frame.state_gas_credit_against_drain_snapshot = + self.state_gas_credit_against_drain; + new_call_frame.state_gas_spill_snapshot = self.state_gas_spill; + new_call_frame.regular_gas_reclassified_snapshot = self.regular_gas_reclassified; self.add_callframe(new_call_frame); @@ -1098,6 +1121,15 @@ impl<'a> VM<'a> { ret_size, memory: old_callframe_memory, state_gas_used_snapshot, + state_gas_refund_pending_snapshot, + state_gas_refund_absorbed_snapshot, + state_gas_reservoir_snapshot, + state_gas_spill_outstanding_snapshot, + state_gas_credit_against_drain_snapshot, + state_gas_spill_snapshot, + regular_gas_reclassified_snapshot, + call_frame_backup, + stack, .. } = executed_call_frame; @@ -1133,32 +1165,94 @@ impl<'a> VM<'a> { match &ctx_result.result { TxResult::Success => { self.current_call_frame.stack.push(SUCCESS)?; - self.merge_call_frame_backup_with_parent(&executed_call_frame.call_frame_backup)?; + self.merge_call_frame_backup_with_parent(&call_frame_backup)?; + + // EIP-8037 clamp-and-spill: on successful child return, flush any pending + // state gas refund into the parent frame (which may absorb all, part, or none). + if self.state_gas_refund_pending > 0 { + let pending = mem::replace(&mut self.state_gas_refund_pending, 0); + self.credit_state_gas_refund(pending)?; + } } - TxResult::Revert(_) => { - // EIP-8037: On child revert, all state gas (used + remaining) - // is returned to the parent's reservoir. - // Per EELS incorporate_child_on_error: - // evm.state_gas_left += child.state_gas_used + child.state_gas_left - // - // In our global-reservoir model this simplifies to: - // new_reservoir = current_reservoir + child_state_gas_used - // because current_reservoir already reflects any sub-child - // restorations (child.state_gas_left in EELS terms). - let child_state_gas_used = - self.state_gas_used.saturating_sub(state_gas_used_snapshot); - self.state_gas_reservoir = self - .state_gas_reservoir - .checked_add(child_state_gas_used) - .ok_or(InternalError::Overflow)?; + TxResult::Revert(err) => { + let outstanding_delta = self + .state_gas_spill_outstanding + .saturating_sub(state_gas_spill_outstanding_snapshot); + let credit_against_drain_delta = self + .state_gas_credit_against_drain + .saturating_sub(state_gas_credit_against_drain_snapshot); + debug_assert!( + outstanding_delta >= credit_against_drain_delta, + "reservoir revert invariant violated: credit_against_drain_delta \ + ({credit_against_drain_delta}) > outstanding_delta \ + ({outstanding_delta})" + ); + self.state_gas_used = state_gas_used_snapshot; + self.state_gas_refund_pending = state_gas_refund_pending_snapshot; + self.state_gas_refund_absorbed = state_gas_refund_absorbed_snapshot; + + if err.is_revert_opcode() { + // REVERT opcode (intentional): pre-PR-2689 behaviour โ€” give the + // un-cancelled spill back to the reservoir; do NOT reclassify to + // regular_gas. state_gas_spill_outstanding stays elevated so the + // spill counts as state-gas in the regular_gas formula's + // subtraction (i.e. excluded from regular_gas). + // + // EELS v1.1.0 burn propagation: do NOT roll back + // `state_gas_credit_against_drain` โ€” leave it at the post-credit + // value so the credit's "burn" propagates up the cascade as + // additional drain_delta in ancestor handle_return_call + // invocations. This implements the + // `parent.state_gas_left += child_used + child_left - child_refund` + // formula across multiple cascade levels, so a subtree's inline + // refund is burned at every incorporate boundary on the way to + // the top (per test_nested_failure_resets_to_tx_reservoir's + // `non_top_refund_burn` sum). + self.state_gas_reservoir = state_gas_reservoir_snapshot + .saturating_add(outstanding_delta) + .saturating_sub(credit_against_drain_delta); + } else { + self.state_gas_credit_against_drain = state_gas_credit_against_drain_snapshot; + // ExceptionalHalt (PR #2689): reclassify the subtree's + // un-cancelled local spill PLUS the credit-cancelled spill + // that wasn't already reclassified at a deeper halt boundary. + // + // - `local_excess` = outstanding_delta - credit_against_drain_delta: + // the un-credited spill in this subtree (un-cancelled). + // - `credit_cancelled_spill` = subtree_gross_spill - outstanding_delta: + // spill that was credited away (e.g. CREATE-halt's NEW_ACCOUNT + // refund). Permanently consumed from gas_remaining; default_hook's + // `regular = raw - state_gas_spill + reclassified` would + // silently drop it. + // - `already_reclassified_in_subtree` = current reclassified - + // snapshot at frame entry: amounts already counted at deeper + // halts. Subtract to avoid double-counting. + let local_excess = outstanding_delta.saturating_sub(credit_against_drain_delta); + let subtree_gross_spill = self + .state_gas_spill + .saturating_sub(state_gas_spill_snapshot); + let credit_cancelled_spill = + subtree_gross_spill.saturating_sub(outstanding_delta); + let already_reclassified_in_subtree = self + .regular_gas_reclassified + .saturating_sub(regular_gas_reclassified_snapshot); + let new_reclassify = local_excess + .saturating_add(credit_cancelled_spill) + .saturating_sub(already_reclassified_in_subtree); + self.regular_gas_reclassified = + self.regular_gas_reclassified.saturating_add(new_reclassify); + self.state_gas_spill_outstanding = state_gas_spill_outstanding_snapshot; + self.state_gas_reservoir = state_gas_reservoir_snapshot; + } + self.current_call_frame.stack.push(FAIL)?; } }; self.tracer.exit_context(ctx_result, false)?; - let mut stack = executed_call_frame.stack; + let mut stack = stack; stack.clear(); self.stack_pool.push(stack); @@ -1177,18 +1271,25 @@ impl<'a> VM<'a> { call_frame_backup, memory: old_callframe_memory, state_gas_used_snapshot, + state_gas_refund_pending_snapshot, + state_gas_refund_absorbed_snapshot, + state_gas_reservoir_snapshot, + state_gas_spill_outstanding_snapshot, + state_gas_credit_against_drain_snapshot, + state_gas_spill_snapshot, + regular_gas_reclassified_snapshot, + stack, .. } = executed_call_frame; old_callframe_memory.clean_from_base(); - let parent_call_frame = &mut self.current_call_frame; - // Return unused gas let unused_gas = gas_limit .checked_sub(ctx_result.gas_used) .ok_or(InternalError::Underflow)?; - parent_call_frame.gas_remaining = parent_call_frame + self.current_call_frame.gas_remaining = self + .current_call_frame .gas_remaining .checked_add(unused_gas as i64) .ok_or(InternalError::Overflow)?; @@ -1196,32 +1297,89 @@ impl<'a> VM<'a> { // What to do, depending on TxResult match ctx_result.result.clone() { TxResult::Success => { - parent_call_frame.stack.push(address_to_word(to))?; + self.current_call_frame.stack.push(address_to_word(to))?; self.merge_call_frame_backup_with_parent(&call_frame_backup)?; + + // EIP-8037 clamp-and-spill: on successful child return, flush any pending + // state gas refund into the parent frame (which may absorb all, part, or none). + if self.state_gas_refund_pending > 0 { + let pending = mem::replace(&mut self.state_gas_refund_pending, 0); + self.credit_state_gas_refund(pending)?; + } } TxResult::Revert(err) => { - // EIP-8037: On child revert, all state gas is returned to the - // parent's reservoir (same logic as handle_return_call). - let child_state_gas_used = - self.state_gas_used.saturating_sub(state_gas_used_snapshot); - self.state_gas_reservoir = self - .state_gas_reservoir - .checked_add(child_state_gas_used) - .ok_or(InternalError::Overflow)?; + // PR #2689 reclassification on child halt โ€” same split as handle_return_call. + let outstanding_delta = self + .state_gas_spill_outstanding + .saturating_sub(state_gas_spill_outstanding_snapshot); + let credit_against_drain_delta = self + .state_gas_credit_against_drain + .saturating_sub(state_gas_credit_against_drain_snapshot); + debug_assert!( + outstanding_delta >= credit_against_drain_delta, + "reservoir revert invariant violated: credit_against_drain_delta \ + ({credit_against_drain_delta}) > outstanding_delta \ + ({outstanding_delta})" + ); + self.state_gas_used = state_gas_used_snapshot; + self.state_gas_refund_pending = state_gas_refund_pending_snapshot; + self.state_gas_refund_absorbed = state_gas_refund_absorbed_snapshot; + + if err.is_revert_opcode() { + // REVERT opcode (matching handle_return_call): leave + // `state_gas_credit_against_drain` elevated so the credit's burn + // propagates up the cascade. See handle_return_call REVERT comment. + self.state_gas_reservoir = state_gas_reservoir_snapshot + .saturating_add(outstanding_delta) + .saturating_sub(credit_against_drain_delta); + } else { + self.state_gas_credit_against_drain = state_gas_credit_against_drain_snapshot; + // ExceptionalHalt (PR #2689): reclassify the subtree's + // un-cancelled local spill PLUS the credit-cancelled spill + // that wasn't already reclassified at a deeper halt boundary. + // Mirrors handle_return_call's formula โ€” see comment there for + // the term-by-term breakdown. Without the credit_cancelled_spill + // term, a nested CREATE child whose initcode credits NEW_ACCOUNT + // and then ExceptionalHalts under-reclassifies state gas by + // exactly AccountCreationCost. + let local_excess = outstanding_delta.saturating_sub(credit_against_drain_delta); + let subtree_gross_spill = self + .state_gas_spill + .saturating_sub(state_gas_spill_snapshot); + let credit_cancelled_spill = + subtree_gross_spill.saturating_sub(outstanding_delta); + let already_reclassified_in_subtree = self + .regular_gas_reclassified + .saturating_sub(regular_gas_reclassified_snapshot); + let new_reclassify = local_excess + .saturating_add(credit_cancelled_spill) + .saturating_sub(already_reclassified_in_subtree); + self.regular_gas_reclassified = + self.regular_gas_reclassified.saturating_add(new_reclassify); + self.state_gas_spill_outstanding = state_gas_spill_outstanding_snapshot; + self.state_gas_reservoir = state_gas_reservoir_snapshot; + } + + // EIP-8037: CREATE's account state gas was charged in the parent before + // the child frame began; no account was created, so refund it per EELS + // `credit_state_gas_refund(evm, create_account_state_gas)`. + if self.env.config.fork >= Fork::Amsterdam { + self.credit_state_gas_refund(self.state_gas_new_account)?; + } // If revert we have to copy the return_data if err.is_revert_opcode() { - parent_call_frame.sub_return_data = ctx_result.output.clone(); + self.current_call_frame.sub_return_data = ctx_result.output.clone(); } - parent_call_frame.stack.push(FAIL)?; + self.current_call_frame.stack.push(FAIL)?; } }; self.tracer.exit_context(ctx_result, false)?; - let mut stack = executed_call_frame.stack; + let mut stack = stack; stack.clear(); self.stack_pool.push(stack); diff --git a/crates/vm/levm/src/utils.rs b/crates/vm/levm/src/utils.rs index 4e9ccc5af6e..b24ac58dd3c 100644 --- a/crates/vm/levm/src/utils.rs +++ b/crates/vm/levm/src/utils.rs @@ -8,8 +8,8 @@ use crate::{ gas_cost::{ self, ACCESS_LIST_ADDRESS_COST, ACCESS_LIST_STORAGE_KEY_COST, BLOB_GAS_PER_BLOB, COLD_ADDRESS_ACCESS_COST, CREATE_BASE_COST, REGULAR_GAS_CREATE, STANDARD_TOKEN_COST, - STATE_GAS_AUTH_TOTAL, STATE_GAS_NEW_ACCOUNT, TOTAL_COST_FLOOR_PER_TOKEN, - WARM_ADDRESS_ACCESS_COST, + STATE_BYTES_PER_AUTH_TOTAL, STATE_BYTES_PER_NEW_ACCOUNT, WARM_ADDRESS_ACCESS_COST, + cost_per_state_byte, floor_tokens_in_access_list, total_cost_floor_per_token, }, vm::{Substate, VM}, }; @@ -337,23 +337,24 @@ impl<'a> VM<'a> { } // 7. Refund if authority exists in the trie. - // EIP-8037 (Amsterdam+): return STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE + // EIP-8037 (Amsterdam+): return STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte // to the state gas reservoir (the new-account portion of the auth state charge). // Pre-Amsterdam: add REFUND_AUTH_PER_EXISTING_ACCOUNT (12500) to global refund counter. // NOTE: Uses `exists` (account_exists in EELS / Exist in geth), NOT `!is_empty()`. // An account can exist in the trie but be empty (e.g., has non-empty storage root). if authority_exists { if self.env.config.fork >= Fork::Amsterdam { - let state_refund = STATE_GAS_NEW_ACCOUNT; + // EELS bal-devnet-6 `set_delegation` (devnets/bal/6 spec): + // `message.state_gas_reservoir += STATE_BYTES_PER_NEW_ACCOUNT ร— cpsb`, + // with NO mutation of intrinsic_state_gas or state_gas_used. Block-level + // `state_gas_used` intentionally stays "inflated" by the refund amount + // โ€” the auth refund is a sender-side credit only in bal-6, not a + // block-accounting reduction. The block-level subtraction lands in + // bal-devnet-7 via the separate `state_refund` channel (EELS PR #2816). + let refund = self.state_gas_new_account; self.state_gas_reservoir = self .state_gas_reservoir - .checked_add(state_refund) - .ok_or(InternalError::Overflow)?; - // Track as intrinsic state gas adjustment (matches EELS intrinsic_state_gas -= refund). - // Do NOT reduce state_gas_used here โ€” that would inflate regular_gas in block accounting. - self.intrinsic_state_gas_refund = self - .intrinsic_state_gas_refund - .checked_add(state_refund) + .checked_add(refund) .ok_or(InternalError::Overflow)?; } else { refunded_gas = refunded_gas @@ -411,6 +412,14 @@ impl<'a> VM<'a> { .checked_add(state_gas) .ok_or(InternalError::Overflow)?; + // EIP-8037 (PR #2689): Capture the intrinsic state gas charged so that top-level + // failure handling can distinguish intrinsic (stays charged) from execution (wiped). + debug_assert_eq!( + self.intrinsic_state_gas_charged, 0, + "intrinsic_state_gas_charged set twice" + ); + self.intrinsic_state_gas_charged = self.state_gas_used; + // EIP-8037 (Amsterdam+): compute state gas reservoir from excess gas_limit. // execution_gas = what remains after all intrinsic gas; regular_gas_budget = how much // regular execution gas is allowed (capped at TX_MAX_GAS_LIMIT_AMSTERDAM); the difference becomes @@ -432,6 +441,8 @@ impl<'a> VM<'a> { .ok_or(InternalError::Overflow)?; self.state_gas_reservoir = reservoir; } + // Capture initial reservoir for block-dimensional regular gas computation. + self.state_gas_reservoir_initial = reservoir; } Ok(()) @@ -464,7 +475,7 @@ impl<'a> VM<'a> { .checked_add(REGULAR_GAS_CREATE) .ok_or(OutOfGas)?; state_gas = state_gas - .checked_add(STATE_GAS_NEW_ACCOUNT) + .checked_add(self.state_gas_new_account) .ok_or(OutOfGas)?; } else { // https://eips.ethereum.org/EIPS/eip-2#specification @@ -499,6 +510,20 @@ impl<'a> VM<'a> { } } + // EIP-7981 (Amsterdam+): access-list data bytes also contribute to the regular arm. + // access_list_cost += floor_tokens_in_access_list * total_cost_floor_per_token + // = access_list_bytes * STANDARD_TOKEN_COST * total_cost_floor_per_token + // Effective: +1280 per address, +2048 per storage key. + if fork >= Fork::Amsterdam { + let al_floor_tokens = floor_tokens_in_access_list(self.tx.access_list()); + let al_data_cost = al_floor_tokens + .checked_mul(total_cost_floor_per_token(fork)) + .ok_or(InternalError::Overflow)?; + access_lists_cost = access_lists_cost + .checked_add(al_data_cost) + .ok_or(InternalError::Overflow)?; + } + regular_gas = regular_gas.checked_add(access_lists_cost).ok_or(OutOfGas)?; // Authorization List Cost @@ -513,12 +538,13 @@ impl<'a> VM<'a> { }; if fork >= Fork::Amsterdam { - // EIP-8037: per-auth regular cost is PER_AUTH_BASE_COST, state is 135 * COST_PER_STATE_BYTE + // EIP-8037: per-auth regular cost is PER_AUTH_BASE_COST, state is STATE_BYTES_PER_AUTH_TOTAL * cost_per_state_byte let regular_auth_cost = PER_AUTH_BASE_COST .checked_mul(amount_of_auth_tuples) .ok_or(InternalError::Overflow)?; regular_gas = regular_gas.checked_add(regular_auth_cost).ok_or(OutOfGas)?; - let state_auth_cost = STATE_GAS_AUTH_TOTAL + let state_auth_cost = self + .state_gas_auth_total .checked_mul(amount_of_auth_tuples) .ok_or(InternalError::Overflow)?; state_gas = state_gas.checked_add(state_auth_cost).ok_or(OutOfGas)?; @@ -536,6 +562,8 @@ impl<'a> VM<'a> { /// Calculates the minimum gas to be consumed in the transaction. pub fn get_min_gas_used(&self) -> Result { + let fork = self.env.config.fork; + // If the transaction is a CREATE transaction, the calldata is emptied and the bytecode is assigned. let calldata = if self.is_create()? { &self.current_call_frame.bytecode.bytecode @@ -543,15 +571,37 @@ impl<'a> VM<'a> { &self.current_call_frame.calldata }; - // tokens_in_calldata = nonzero_bytes_in_calldata * 4 + zero_bytes_in_calldata - // tx_calldata = nonzero_bytes_in_calldata * 16 + zero_bytes_in_calldata * 4 - // this is actually tokens_in_calldata * STANDARD_TOKEN_COST - // see it in https://eips.ethereum.org/EIPS/eip-7623 - let tokens_in_calldata: u64 = gas_cost::tx_calldata(calldata)? / STANDARD_TOKEN_COST; + // EIP-7976 floor tokens: for the floor arm, all calldata bytes count unweighted. + // floor_tokens_in_calldata = (zero_bytes + nonzero_bytes) * STANDARD_TOKEN_COST + // Pre-Amsterdam uses the weighted EIP-7623 formula: (nonzero * 16 + zero * 4) / 4 + let mut tokens_in_calldata: u64 = if fork >= Fork::Amsterdam { + // EIP-7976: floor tokens = total_bytes * STANDARD_TOKEN_COST (unweighted). + let total_bytes: u64 = calldata + .len() + .try_into() + .map_err(|_| InternalError::TypeConversion)?; + total_bytes + .checked_mul(STANDARD_TOKEN_COST) + .ok_or(InternalError::Overflow)? + } else { + // Pre-Amsterdam: weighted EIP-7623 token count. + gas_cost::tx_calldata(calldata)? / STANDARD_TOKEN_COST + }; + + // EIP-7981 (Amsterdam+): access-list data bytes fold into the floor-token count. + // floor_tokens_in_access_list = access_list_bytes * STANDARD_TOKEN_COST + // where access_list_bytes = 20 * address_count + 32 * storage_key_count. + if fork >= Fork::Amsterdam { + let al_floor_tokens = floor_tokens_in_access_list(self.tx.access_list()); + tokens_in_calldata = tokens_in_calldata + .checked_add(al_floor_tokens) + .ok_or(InternalError::Overflow)?; + } - // min_gas_used = TX_BASE_COST + TOTAL_COST_FLOOR_PER_TOKEN * tokens_in_calldata + // min_gas_used = TX_BASE_COST + total_cost_floor_per_token(fork) * tokens + // EIP-7976 (Amsterdam+) raises TOTAL_COST_FLOOR_PER_TOKEN from 10 to 16. let mut min_gas_used: u64 = tokens_in_calldata - .checked_mul(TOTAL_COST_FLOOR_PER_TOKEN) + .checked_mul(total_cost_floor_per_token(fork)) .ok_or(InternalError::Overflow)?; min_gas_used = min_gas_used @@ -590,6 +640,163 @@ impl<'a> VM<'a> { } } +/// Compute `(regular, state)` intrinsic gas for a transaction without needing +/// a full VM instance. Mirrors `VM::get_intrinsic_gas` but operates on the raw +/// transaction, fork, and block gas limit (for cpsb derivation). Pre-Amsterdam +/// returns `(regular, 0)`. +/// +/// Used by the block executor to perform the EIP-8037 (PR #2703) per-tx 2D +/// inclusion check before the tx runs. +pub fn intrinsic_gas_dimensions( + tx: &Transaction, + fork: Fork, + block_gas_limit: u64, +) -> Result<(u64, u64), VMError> { + let mut regular_gas: u64 = 0; + let mut state_gas: u64 = 0; + + let (state_gas_new_account, state_gas_auth_total) = if fork >= Fork::Amsterdam { + let cpsb = cost_per_state_byte(block_gas_limit); + ( + STATE_BYTES_PER_NEW_ACCOUNT + .checked_mul(cpsb) + .ok_or(InternalError::Overflow)?, + STATE_BYTES_PER_AUTH_TOTAL + .checked_mul(cpsb) + .ok_or(InternalError::Overflow)?, + ) + } else { + (0, 0) + }; + + // Calldata cost (EIP-2028 weighted) + let calldata_cost = gas_cost::tx_calldata(tx.data())?; + regular_gas = regular_gas.checked_add(calldata_cost).ok_or(OutOfGas)?; + + // Base cost + regular_gas = regular_gas.checked_add(TX_BASE_COST).ok_or(OutOfGas)?; + + let is_create = matches!(tx.to(), TxKind::Create); + if is_create { + if fork >= Fork::Amsterdam { + regular_gas = regular_gas + .checked_add(REGULAR_GAS_CREATE) + .ok_or(OutOfGas)?; + state_gas = state_gas + .checked_add(state_gas_new_account) + .ok_or(OutOfGas)?; + } else { + regular_gas = regular_gas.checked_add(CREATE_BASE_COST).ok_or(OutOfGas)?; + } + + // EIP-3860 init code words (Shanghai+) + if fork >= Fork::Shanghai { + let words = tx.data().len().div_ceil(WORD_SIZE); + let double_words: u64 = words + .checked_mul(2) + .ok_or(OutOfGas)? + .try_into() + .map_err(|_| InternalError::TypeConversion)?; + regular_gas = regular_gas.checked_add(double_words).ok_or(OutOfGas)?; + } + } + + // Access list cost + let mut access_lists_cost: u64 = 0; + for (_, keys) in tx.access_list() { + access_lists_cost = access_lists_cost + .checked_add(ACCESS_LIST_ADDRESS_COST) + .ok_or(OutOfGas)?; + for _ in keys { + access_lists_cost = access_lists_cost + .checked_add(ACCESS_LIST_STORAGE_KEY_COST) + .ok_or(OutOfGas)?; + } + } + + // EIP-7981 (Amsterdam+): access-list data bytes fold into regular gas + if fork >= Fork::Amsterdam { + let al_floor_tokens = floor_tokens_in_access_list(tx.access_list()); + let al_data_cost = al_floor_tokens + .checked_mul(total_cost_floor_per_token(fork)) + .ok_or(InternalError::Overflow)?; + access_lists_cost = access_lists_cost + .checked_add(al_data_cost) + .ok_or(InternalError::Overflow)?; + } + regular_gas = regular_gas.checked_add(access_lists_cost).ok_or(OutOfGas)?; + + // Authorization list cost + let amount_of_auth_tuples: u64 = match tx.authorization_list() { + None => 0, + Some(list) => list + .len() + .try_into() + .map_err(|_| InternalError::TypeConversion)?, + }; + + if fork >= Fork::Amsterdam { + let regular_auth_cost = PER_AUTH_BASE_COST + .checked_mul(amount_of_auth_tuples) + .ok_or(InternalError::Overflow)?; + regular_gas = regular_gas.checked_add(regular_auth_cost).ok_or(OutOfGas)?; + let state_auth_cost = state_gas_auth_total + .checked_mul(amount_of_auth_tuples) + .ok_or(InternalError::Overflow)?; + state_gas = state_gas.checked_add(state_auth_cost).ok_or(OutOfGas)?; + } else { + let auth_cost = PER_EMPTY_ACCOUNT_COST + .checked_mul(amount_of_auth_tuples) + .ok_or(InternalError::Overflow)?; + regular_gas = regular_gas.checked_add(auth_cost).ok_or(OutOfGas)?; + } + + Ok((regular_gas, state_gas)) +} + +/// Standalone EIP-7623/7976/7981 floor gas for a transaction. Mirrors +/// [`VM::get_min_gas_used`] but operates on the raw transaction + fork, so it +/// can be called by mempool admission / the payload builder without needing a +/// VM instance. Returns `TX_BASE_COST + floor_rate * total_floor_tokens`. +/// +/// Amsterdam+ uses the unweighted EIP-7976 floor (16 gas/token = 64 gas/byte) +/// and folds EIP-7981 access-list data bytes into the token count. Pre- +/// Amsterdam uses the weighted EIP-7623 formula. +/// +/// A mismatch between this and `VM::get_min_gas_used` would cause mempool +/// admission to drift from VM rejection; keep the two in sync. The +/// `test_intrinsic_parity_*` suite also guards this. +pub fn intrinsic_gas_floor(tx: &Transaction, fork: Fork) -> Result { + // EIP-7976: floor tokens count ALL calldata bytes unweighted. For CREATE + // txs the calldata is the init code. Mirrors `get_min_gas_used`. + let calldata = tx.data(); + + let mut tokens_in_calldata: u64 = if fork >= Fork::Amsterdam { + let total_bytes: u64 = calldata + .len() + .try_into() + .map_err(|_| InternalError::TypeConversion)?; + total_bytes + .checked_mul(STANDARD_TOKEN_COST) + .ok_or(InternalError::Overflow)? + } else { + gas_cost::tx_calldata(calldata)? / STANDARD_TOKEN_COST + }; + + if fork >= Fork::Amsterdam { + let al_floor_tokens = floor_tokens_in_access_list(tx.access_list()); + tokens_in_calldata = tokens_in_calldata + .checked_add(al_floor_tokens) + .ok_or(InternalError::Overflow)?; + } + + tokens_in_calldata + .checked_mul(total_cost_floor_per_token(fork)) + .ok_or(InternalError::Overflow)? + .checked_add(TX_BASE_COST) + .ok_or(InternalError::Overflow.into()) +} + /// Converts Account to LevmAccount /// The problem with this is that we don't have the storage root. pub fn account_to_levm_account(account: Account) -> (LevmAccount, Code) { diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index df15dbc1d09..3f8a19a75ff 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -8,6 +8,10 @@ use crate::{ ContextResult, ExceptionalHalt, ExecutionReport, InternalError, OpcodeResult, TxResult, VMError, }, + gas_cost::{ + STATE_BYTES_PER_AUTH_TOTAL, STATE_BYTES_PER_NEW_ACCOUNT, STATE_BYTES_PER_STORAGE_SET, + cost_per_state_byte as compute_cost_per_state_byte, + }, hooks::{ backup_hook::BackupHook, hook::{Hook, get_hooks}, @@ -445,10 +449,65 @@ pub struct VM<'a> { pub state_gas_used: u64, /// EIP-8037: State gas reservoir pre-funded from excess gas_limit (Amsterdam+). pub state_gas_reservoir: u64, - /// EIP-8037/EIP-7702: Reduction to intrinsic state gas when existing authorities - /// are found during set_delegation. Tracked separately because state_gas_used - /// must not be reduced (it would inflate regular_gas in block accounting). - pub intrinsic_state_gas_refund: u64, + /// EIP-8037: Initial reservoir at tx start (before any execution). Captured in + /// add_intrinsic_gas so block-dimensional regular gas can be computed + /// independently of mid-tx reservoir activity (auth refunds, SSTORE credits). + pub state_gas_reservoir_initial: u64, + /// EIP-8037: Cumulative state gas that spilled to regular gas during execution + /// (when reservoir was insufficient). Subtracted when computing dimensional + /// regular gas for block accounting โ€” EELS charge_state_gas spills don't + /// increment regular_gas_used. + pub state_gas_spill: u64, + /// EIP-8037: Outstanding spill โ€” the portion of `state_gas_spill` not yet cancelled + /// by an inline credit (SSTORE 0โ†’Nโ†’0 or CREATE failure). Decremented inside + /// `credit_state_gas_refund` when the clamped credit matches the current frame's + /// own spill delta. Used by `incorporate_child_on_error` math at revert so a + /// reverting sub-frame's locally-cancelled spills don't leak into the grandparent's + /// reservoir refund (cf. `sstore_restoration_create_init_revert`). NOT restored on + /// revert โ€” outstanding spill from a reverting child legitimately propagates up. + pub state_gas_spill_outstanding: u64, + /// EIP-8037: Cumulative credits that went toward cancelling drains (not spills). + /// Incremented inside `credit_state_gas_refund` by the portion of the clamped + /// credit that was not matched to outstanding spill. Used at revert boundaries in + /// place of `state_gas_refund_absorbed` so the reservoir math (`R_snap + spill - + /// credit`) stays consistent after the spill side is split between "still outstanding" + /// and "already cancelled by local credit". Restored from snapshot on child revert. + pub state_gas_credit_against_drain: u64, + /// EIP-8037 (PR #2689): Cumulative state-gas amount reclassified to regular_gas_used + /// because of an ExceptionalHalt at any frame. On halt, the spec wipes the frame's + /// state-gas usage and adds `state_gas_used + state_gas_left - reservoir_at_entry` + /// (the un-cancelled spill) to `regular_gas_used`. This counter accumulates that + /// reclassified amount across all halts in the tx, and is added to the regular-gas + /// dimension at finalization. Pre-PR-2689 behavior gave the spill back to the + /// reservoir; under PR #2689 it becomes regular gas instead. + pub regular_gas_reclassified: u64, + /// EIP-8037: Dynamic cost per state byte (computed from block_gas_limit, Amsterdam+). + pub cost_per_state_byte: u64, + /// EIP-8037: State gas for new account creation (STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte). + pub state_gas_new_account: u64, + /// EIP-8037: State gas for storage slot creation (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte). + pub state_gas_storage_set: u64, + /// EIP-8037: State gas for EIP-7702 auth total (STATE_BYTES_PER_AUTH_TOTAL * cost_per_state_byte). + pub state_gas_auth_total: u64, + /// EIP-8037 clamp-and-spill: state gas refund amount that has been clamped by child frames but + /// not yet absorbed by an ancestor frame. Flushed into the current frame on successful sub-call + /// return, and restored from snapshot on revert. + pub state_gas_refund_pending: u64, + /// EIP-8037 clamp-and-spill: cumulative total of state gas refunds absorbed by any frame so + /// far in this transaction (across all depths). Used at finalization to compute net + /// state_gas_used. Restored from snapshot on child revert. + pub state_gas_refund_absorbed: u64, + /// EIP-8037 (PR #2689): snapshot of state_gas_used taken immediately after intrinsic gas + /// is charged. On top-level tx failure, only this portion stays charged; the execution + /// portion (state_gas_used - intrinsic_state_gas_charged) is wiped back to the reservoir. + pub intrinsic_state_gas_charged: u64, + /// EIP-8037 (PR #2689): the `state_gas_reservoir` value at the moment the top-level + /// `process_message_call` begins โ€” i.e. AFTER intrinsic gas, AFTER any pre-execution + /// adjustments (EIP-7702 auth refunds add to the reservoir before execution starts). + /// This is what the spec uses as `message.state_gas_reservoir` for the top-level frame + /// when applying the halt rule: + /// excess = (state_gas_used + state_gas_left) - reservoir_at_entry + pub state_gas_reservoir_at_top_message_entry: u64, /// The opcode table mapping opcodes to opcode handlers for fast lookup. /// Build dynamically according to the given fork config. pub(crate) opcode_table: [OpCodeFn; 256], @@ -473,6 +532,23 @@ impl<'a> VM<'a> { let fork = env.config.fork; + #[expect( + clippy::arithmetic_side_effects, + reason = "byte-count constants are small (<200) and cpsb is bounded by block_gas_limit/year formula" + )] + let (cpsb, state_gas_new_account, state_gas_storage_set, state_gas_auth_total) = + if fork >= Fork::Amsterdam { + let cpsb = compute_cost_per_state_byte(env.block_gas_limit); + ( + cpsb, + STATE_BYTES_PER_NEW_ACCOUNT * cpsb, + STATE_BYTES_PER_STORAGE_SET * cpsb, + STATE_BYTES_PER_AUTH_TOTAL * cpsb, + ) + } else { + (0, 0, 0, 0) + }; + let mut vm = Self { call_frames: Vec::new(), substate, @@ -486,7 +562,19 @@ impl<'a> VM<'a> { vm_type, state_gas_used: 0, state_gas_reservoir: 0, - intrinsic_state_gas_refund: 0, + state_gas_reservoir_initial: 0, + state_gas_spill: 0, + state_gas_spill_outstanding: 0, + state_gas_credit_against_drain: 0, + regular_gas_reclassified: 0, + cost_per_state_byte: cpsb, + state_gas_new_account, + state_gas_storage_set, + state_gas_auth_total, + state_gas_refund_pending: 0, + state_gas_refund_absorbed: 0, + intrinsic_state_gas_charged: 0, + state_gas_reservoir_at_top_message_entry: 0, current_call_frame: CallFrame::new( env.origin, callee, @@ -565,6 +653,107 @@ impl<'a> VM<'a> { .state_gas_used .checked_add(gas) .ok_or(InternalError::Overflow)?; + // Track the spill amount for block-accounting: EELS charge_state_gas spills + // don't count toward regular_gas_used for the regular dimension. + self.state_gas_spill = self + .state_gas_spill + .checked_add(spill) + .ok_or(InternalError::Overflow)?; + // Mirror the increment on `state_gas_spill_outstanding` โ€” `credit_state_gas_refund` + // may cancel part of this later; the remainder is what the revert math sees. + self.state_gas_spill_outstanding = self + .state_gas_spill_outstanding + .checked_add(spill) + .ok_or(InternalError::Overflow)?; + Ok(()) + } + + /// EIP-8037 clamp-and-spill: credit `amount` of state gas refund to the current frame. + /// + /// The refund is clamped to the unrefunded local charge of the current frame. Any + /// remainder that cannot be absorbed here is added to `state_gas_refund_pending` for + /// the parent frame to absorb on successful return. + /// + /// The absorbed portion is also added to `state_gas_refund_absorbed`, the VM-level + /// running total used at finalization to compute net `state_gas_used`. + /// + /// Must only be called for Amsterdam+ forks. + pub fn credit_state_gas_refund(&mut self, amount: u64) -> Result<(), VMError> { + debug_assert!( + self.env.config.fork >= Fork::Amsterdam, + "credit_state_gas_refund called pre-Amsterdam" + ); + // Local charge = what this frame has put into state_gas_used minus what it has + // already had refunded back. The snapshot captures state_gas_used at frame entry. + let local_charged = self + .state_gas_used + .saturating_sub(self.current_call_frame.state_gas_used_snapshot); + let already_refunded = self.current_call_frame.state_gas_refund; + debug_assert!( + already_refunded <= local_charged, + "state refund invariant violated: already_refunded > local_charged" + ); + let local_unrefunded = local_charged + .checked_sub(already_refunded) + .ok_or(InternalError::Underflow)?; + let clamped = amount.min(local_unrefunded); + // clamped = amount.min(...) so amount - clamped cannot underflow. + #[expect( + clippy::arithmetic_side_effects, + reason = "clamped <= amount by construction" + )] + let spill = amount - clamped; + self.current_call_frame.state_gas_refund = self + .current_call_frame + .state_gas_refund + .checked_add(clamped) + .ok_or(InternalError::Overflow)?; + self.state_gas_refund_pending = self + .state_gas_refund_pending + .checked_add(spill) + .ok_or(InternalError::Overflow)?; + self.state_gas_refund_absorbed = self + .state_gas_refund_absorbed + .checked_add(clamped) + .ok_or(InternalError::Overflow)?; + // Split the clamped credit between "cancels this frame's outstanding spill" and + // "cancels a drain". The first portion decrements `state_gas_spill_outstanding` + // so a grandparent revert's reservoir math sees only un-cancelled spill. The + // second portion accumulates into `state_gas_credit_against_drain` and appears + // in the revert formula as the subtraction term. + // + // Invariant (crucial for reservoir correctness): + // `state_gas_spill_outstanding - snapshot` counts only spill increments that + // happened INSIDE the current frame (or its subtree, propagated up on revert). + // It excludes the parent's pre-child spills because those are baked into the + // snapshot captured at child-frame entry. Therefore `applied_to_spill` never + // double-cancels a spill that's already been accounted for at a grandparent + // boundary. Changing this subtraction, or reading `state_gas_spill` instead, + // breaks `sstore_restoration_create_init_revert`. + let frame_outstanding_delta = self + .state_gas_spill_outstanding + .saturating_sub(self.current_call_frame.state_gas_spill_outstanding_snapshot); + let applied_to_spill = clamped.min(frame_outstanding_delta); + // clamped >= applied_to_spill by construction. + #[expect( + clippy::arithmetic_side_effects, + reason = "applied_to_spill <= clamped by construction" + )] + let applied_to_drain = clamped - applied_to_spill; + self.state_gas_spill_outstanding = self + .state_gas_spill_outstanding + .checked_sub(applied_to_spill) + .ok_or(InternalError::Underflow)?; + self.state_gas_credit_against_drain = self + .state_gas_credit_against_drain + .checked_add(applied_to_drain) + .ok_or(InternalError::Overflow)?; + // Refill the reservoir with the absorbed portion so subsequent state-gas charges + // in the same tx can draw from it โ€” matches EELS `state_gas_left += applied`. + self.state_gas_reservoir = self + .state_gas_reservoir + .checked_add(clamped) + .ok_or(InternalError::Overflow)?; Ok(()) } @@ -576,6 +765,12 @@ impl<'a> VM<'a> { return Err(e); } + // EIP-8037 (PR #2689): snapshot the reservoir AFTER prepare_execution + // (intrinsic gas charged + EIP-7702 auth refunds applied). This is the + // "state_gas_reservoir" passed to the top-level message in EELS, used + // by the halt rule to compute the regular-gas reclassification. + self.state_gas_reservoir_at_top_message_entry = self.state_gas_reservoir; + // Clear callframe backup so that changes made in prepare_execution are written in stone. // We want to apply these changes even if the Tx reverts. E.g. Incrementing sender nonce self.current_call_frame.call_frame_backup.clear(); @@ -636,6 +831,21 @@ impl<'a> VM<'a> { self.crypto, ); + // EIP-8037 Amsterdam 2D accounting recomputes `block_gas_used` from + // `raw_consumed = gas_limit - gas_remaining` inside `refund_sender`. On a + // top-level precompile exceptional halt, `handle_precompile_result` already + // sets `ContextResult.gas_used = gas_limit`, but `gas_remaining` retains the + // untouched forwarded amount โ€” under Amsterdam that would make the block + // report only the intrinsic portion. Zero it here so the block matches the + // `gas_used = gas_limit` contract from `handle_precompile_result`. Pre-Amsterdam + // reads `ctx_result.gas_used` directly and is unaffected by this path either way. + if self.env.config.fork >= Fork::Amsterdam + && let Ok(ctx) = &result + && !ctx.is_success() + { + gas_remaining = 0; + } + call_frame.gas_remaining = gas_remaining as i64; return result; @@ -733,6 +943,96 @@ impl<'a> VM<'a> { &mut self, mut ctx_result: ContextResult, ) -> Result { + // EIP-8037 (PR #2689): On top-level tx failure (REVERT, ExceptionalHalt, or OOG), + // wipe the EXECUTION portion of state-gas (intrinsic state-gas STAYS charged) so + // the block sees only `intrinsic_state_gas_charged` in the state dimension. For + // REVERT, refill the reservoir with the execution portion so the user's + // `gas_used -= reservoir` subtraction in refund_sender returns both the entry + // reservoir and any spill that decremented `gas_remaining` (matches EELS fork.py + // top-level `state_gas_left += state_gas_used`). For ExceptionalHalt, restore the + // reservoir to its entry value and reclassify the residual gross spill to + // `regular_gas_used`. Collision is handled separately in the hook. See inline + // comments below for the reclassification formula. + if self.env.config.fork >= Fork::Amsterdam + && !ctx_result.is_success() + && !ctx_result.is_collision() + { + debug_assert!( + self.state_gas_used >= self.intrinsic_state_gas_charged, + "invariant: intrinsic is a floor on state_gas_used ({} >= {})", + self.state_gas_used, + self.intrinsic_state_gas_charged + ); + // Execution state gas still "on the books" โ€” gross charge minus intrinsic and + // minus any credits already accounted for via credit_state_gas_refund (which + // already bumped reservoir + absorbed). This excludes double-counting when a + // tx credits a refund mid-execution and then fails. + let execution_portion = self + .state_gas_used + .saturating_sub(self.intrinsic_state_gas_charged) + .saturating_sub(self.state_gas_refund_absorbed) + .saturating_sub(self.state_gas_refund_pending); + self.state_gas_refund_absorbed = self + .state_gas_refund_absorbed + .saturating_add(execution_portion); + + if ctx_result.is_revert_opcode() { + // REVERT: refill the reservoir with the un-refunded execution portion. + // This matches EELS fork.py:1077 `state_gas_left += state_gas_used` at + // top-level Revert: the user gets back BOTH the entry reservoir AND any + // spill that came from `gas_remaining`, via the single + // `gas_used -= reservoir` subtraction in refund_sender. + self.state_gas_reservoir = + self.state_gas_reservoir.saturating_add(execution_portion); + } else { + // ExceptionalHalt (PR #2689): apply the spec halt rule to the top-level + // message uniformly, regardless of whether intrinsic_state was charged. + // + // Per EELS amsterdam/vm/interpreter.py::process_message: + // total_state = evm.state_gas_used + evm.state_gas_left + // reservoir = evm.message.state_gas_reservoir # at frame entry + // if total_state > reservoir: + // evm.regular_gas_used += total_state - reservoir + // + // Because EELS's `credit_state_gas_refund` decrements `state_gas_used` + // and increments `state_gas_left` by the same amount, `total_state` is + // invariant under credits. Hence `total_state - reservoir` reduces to + // the gross spill `S` that originally exceeded the entry reservoir. + // + // In ethrex's flat-reservoir model: `state_gas_spill` accumulates the + // gross lifetime spill (never decremented). At the top message: + // `gross_spill - already_reclassified` + // gives the residual still to be re-classified, where + // `already_reclassified` deduplicates against deeper-frame halts that + // already moved parts of the spill into the regular dim. + // + // The previous non-CREATE-tx branch used + // `max(spill_outstanding, reservoir_surplus)`, which dropped the + // residual outstanding spill that wasn't cancelled by a credit. That + // formula diverged from EELS by `min(applied_to_spill, S - applied_to_spill)` + // whenever a credit only partially cancelled outstanding spill โ€” see + // `test_top_halt_after_partial_credit_to_spill_diverges_from_eels`. + // + // `state_gas_credit_against_drain` plays no role here: drain credits + // already affect `state_gas_refund_absorbed` (reduces net state-gas at + // finalize) and refill `state_gas_reservoir` via `credit_state_gas_refund`, + // so they have no further role in top-halt reclassification. A prior + // formula subtracted `min(credit_against_drain, regular_gas_reclassified)` + // from the gross spill, but that double-counts the already-reclassified + // amount whenever a deeper halt has reclassified its subtree's spill + // (e.g. nested CREATEs that all halt) โ€” see + // `test_top_halt_phantom_drain_does_not_cancel_real_spill` for the + // phantom-drain case (cap was already 0) and bal-devnet-6 block 597 for + // the nested-halt case the cap broke. + let reclassify = self + .state_gas_spill + .saturating_sub(self.regular_gas_reclassified); + self.regular_gas_reclassified = + self.regular_gas_reclassified.saturating_add(reclassify); + self.state_gas_reservoir = self.state_gas_reservoir_at_top_message_entry; + } + } + for hook in self.hooks.clone() { hook.borrow_mut() .finalize_execution(self, &mut ctx_result)?; @@ -748,14 +1048,26 @@ impl<'a> VM<'a> { Vec::new() }; + // EIP-8037 clamp-and-spill: subtract execution state gas refunds. + // `intrinsic_state_gas` is immutable per EELS fork.py โ€” auth refunds on existing + // signers go only to the reservoir (for sender refund), not block-accounted + // state_gas. state_gas_refund_absorbed holds ALL refunds absorbed by any frame. + // state_gas_refund_pending holds any remainder not yet absorbed by an ancestor + // (can only be non-zero at the top level if the refund amount exceeded all charges). + // These are NOT routed through substate.refunded_gas (regular-gas refund counter). + let execution_state_gas_refund = self + .state_gas_refund_absorbed + .saturating_add(self.state_gas_refund_pending); + let net_state_gas_used = self + .state_gas_used + .saturating_sub(execution_state_gas_refund); + let report = ExecutionReport { result: ctx_result.result.clone(), gas_used: ctx_result.gas_used, gas_spent: ctx_result.gas_spent, gas_refunded: self.substate.refunded_gas, - state_gas_used: self - .state_gas_used - .saturating_sub(self.intrinsic_state_gas_refund), + state_gas_used: net_state_gas_used, output: std::mem::take(&mut ctx_result.output), logs, }; diff --git a/crates/vm/lib.rs b/crates/vm/lib.rs index 5f99417ab9f..2f0f1d01a49 100644 --- a/crates/vm/lib.rs +++ b/crates/vm/lib.rs @@ -6,10 +6,19 @@ mod witness_db; pub mod backends; +/// EIP-8037 (Amsterdam+, PR #2703) per-tx 2D inclusion check. Re-exported so the +/// payload builder can enforce it with identical semantics to the validator. +pub use backends::levm::check_2d_gas_allowance; pub use backends::{BlockExecutionResult, Evm}; pub use db::{DynVmDatabase, VmDatabase}; pub use errors::EvmError; pub use ethrex_levm::precompiles::{PrecompileCache, precompiles_for_fork}; +/// EIP-8037 intrinsic gas split `(regular, state)` for a transaction. +/// Re-exported for mempool / payload-builder use. +pub use ethrex_levm::utils::intrinsic_gas_dimensions; +/// EIP-7623/7976/7981 floor gas for a transaction. Re-exported so the mempool +/// can match the VM's `validate_min_gas_limit` check at admission time. +pub use ethrex_levm::utils::intrinsic_gas_floor; pub use execution_result::ExecutionResult; pub use witness_db::GuestProgramStateWrapper; pub mod system_contracts; diff --git a/docs/developers/l1/testing/hive.md b/docs/developers/l1/testing/hive.md index 7db3e6ad690..4e82b0f9ecf 100644 --- a/docs/developers/l1/testing/hive.md +++ b/docs/developers/l1/testing/hive.md @@ -289,8 +289,8 @@ The workflow uses fork-specific fixtures to ensure comprehensive test coverage: ```yaml # Amsterdam tests use fixtures_bal (includes BAL-specific tests) if [[ "$SIM_LIMIT" == *"fork_Amsterdam"* ]]; then - FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.6.1/fixtures_bal.tar.gz" - FLAGS+=" --sim.buildarg branch=devnets/bal/3" + FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v6.0.0/fixtures_bal.tar.gz" + FLAGS+=" --sim.buildarg branch=devnets/bal/4" else # Other forks use fixtures_develop (comprehensive coverage including static tests) FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/v5.3.0/fixtures_develop.tar.gz" @@ -310,10 +310,10 @@ Contents: https://github.com/ethereum/execution-spec-tests/releases/download/v5.3.0/fixtures_develop.tar.gz # .fixtures_url_amsterdam -https://github.com/ethereum/execution-spec-tests/releases/download/bal@v5.6.1/fixtures_bal.tar.gz +https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v6.0.0/fixtures_bal.tar.gz ``` -**Note**: The CI workflow uses `fixtures_bal` with `branch=devnets/bal/3` for Amsterdam tests, and `fixtures_develop` with `branch=forks/osaka` for other forks. +**Note**: The CI workflow uses `fixtures_bal` with `branch=devnets/bal/4` for Amsterdam tests, and `fixtures_develop` with `branch=forks/osaka` for other forks. ## Updating Repository Versions @@ -330,8 +330,8 @@ To update to a different fork or newer versions: For Amsterdam tests (fixtures_bal): ```yaml - FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/bal@/fixtures_bal.tar.gz" - FLAGS+=" --sim.buildarg branch=devnets/bal/3" + FLAGS+=" --sim.buildarg fixtures=https://github.com/ethereum/execution-spec-tests/releases/download/bal%40/fixtures_bal.tar.gz" + FLAGS+=" --sim.buildarg branch=devnets/bal/4" ``` For other forks (fixtures_develop): @@ -345,7 +345,7 @@ To update to a different fork or newer versions: ```bash # For Amsterdam fixtures - echo "https://github.com/ethereum/execution-spec-tests/releases/download/bal@/fixtures_bal.tar.gz" > tooling/ef_tests/blockchain/.fixtures_url_amsterdam + echo "https://github.com/ethereum/execution-spec-tests/releases/download/bal%40/fixtures_bal.tar.gz" > tooling/ef_tests/blockchain/.fixtures_url_amsterdam # For other forks echo "https://github.com/ethereum/execution-spec-tests/releases/download/v/fixtures_develop.tar.gz" > tooling/ef_tests/blockchain/.fixtures_url ``` diff --git a/docs/known_issues.md b/docs/known_issues.md new file mode 100644 index 00000000000..2a17991482e --- /dev/null +++ b/docs/known_issues.md @@ -0,0 +1,220 @@ +# Known Issues + +Tests intentionally excluded from CI. Source of truth for the **Known +Issues** section the L1 workflow appends to each ef-tests job summary +and posts as a sticky PR comment. + +## Hive โ€” bal-devnet-6 Amsterdam consume-engine tests โ€” 32 functions / 54 cases + +Same root cause as the blockchain-runner skip list (see *EF Tests โ€” +Blockchain* below): snobal-devnet-6 fixtures expect bal-devnet-6 spec +semantics, but our impl runs ahead due to the bal-devnet-7-prep +`set_delegation` SELFDESTRUCT-style refund subtraction. These fixtures +are routed through hive's `eels/consume-engine` simulator and produce +the same failures. Excluded via `KNOWN_EXCLUDED_TESTS` (substring +match on `test_[fork_Amsterdam`, anchoring to the Amsterdam fork +so legacy Prague/Osaka variants still run). + +
+Affected EELS test functions (32) + +- `test_auth_refund_block_gas_accounting` +- `test_auth_refund_bypasses_one_fifth_cap` +- `test_auth_state_gas_scales_with_cpsb` +- `test_auth_with_calldata_and_access_list` +- `test_auth_with_multiple_sstores` +- `test_authorization_exact_state_gas_boundary` +- `test_authorization_to_precompile_address` +- `test_authorization_with_sstore` +- `test_bal_7702_delegation_clear` +- `test_bal_7702_delegation_create` +- `test_bal_7702_delegation_update` +- `test_bal_7702_double_auth_reset` +- `test_bal_7702_double_auth_swap` +- `test_bal_7702_null_address_delegation_no_code_change` +- `test_bal_all_transaction_types` +- `test_bal_selfdestruct_to_7702_delegation` +- `test_bal_withdrawal_to_7702_delegation` +- `test_duplicate_signer_authorizations` +- `test_existing_account_auth_header_gas_used_uses_worst_case` +- `test_existing_account_refund` +- `test_existing_account_refund_enables_sstore` +- `test_existing_auth_with_reverted_execution_preserves_intrinsic` +- `test_many_authorizations_state_gas` +- `test_mixed_auths_header_gas_used_uses_worst_case` +- `test_mixed_new_and_existing_auths` +- `test_mixed_valid_and_invalid_auths` +- `test_multi_tx_block_auth_refund_and_sstore` +- `test_multiple_refund_types_in_one_tx` +- `test_simple_gas_accounting` +- `test_sstore_state_gas_all_tx_types` +- `test_transfer_with_all_tx_types` +- `test_varying_calldata_costs` + +
+ +## EF Tests โ€” Stateless coverage narrowed to EIP-8025 optional-proofs + +`make -C tooling/ef_tests/blockchain test` calls `test-stateless-zkevm` +instead of `test-stateless`. The zkevm@v0.3.3 fixtures are filled against +bal@v5.6.1, out of sync with current bal spec; the broad target trips ~549 +fixtures. Re-broaden once the zkevm bundle is regenerated. + +
+Why and resolution path + +[PR #6527](https://github.com/lambdaclass/ethrex/pull/6527) broadened +`test-stateless` to extract the entire `for_amsterdam/` tree from the +zkevm bundle and run all of it under `--features stateless`; combined with +this branch's bal-devnet-6+ semantics (and bal-devnet-7-prep +`set_delegation` re-application) that scope produces ~549 +`GasUsedMismatch` / `ReceiptsRootMismatch` / +`BlockAccessListHashMismatch` failures. + +`test-stateless-zkevm` filters cargo to the `eip8025_optional_proofs` +suite, which still validates the stateless harness without the bal-version +mismatch. + +Re-broaden by switching `test:` back to `test-stateless` in +`tooling/ef_tests/blockchain/Makefile` once the zkevm bundle is regenerated +against the current bal spec. + +
+ +## EF Tests โ€” Blockchain bal-devnet-6 (Amsterdam fork) โ€” 74 tests + +snobal-devnet-6 fixtures expect bal-devnet-6 spec semantics, but our impl +runs ahead due to the bal-devnet-7-prep `set_delegation` SELFDESTRUCT-style +refund subtraction. Skipped in +`tooling/ef_tests/blockchain/tests/all.rs::SKIPPED_BASE`, anchored on +`[fork_Amsterdam` so legacy Prague / Osaka variants still run. + +
+Bucket breakdown (74 total) and resolution path + +| EIP | Bucket | Count | +| -------- | ----------------------------------------------------- | ----- | +| EIP-7702 | `set_code_txs` | 24 | +| EIP-7702 | `set_code_txs_2` | 15 | +| EIP-7702 | `gas` | 1 | +| EIP-8037 | `state_gas_set_code` | 17 | +| EIP-8037 | `state_gas_pricing` | 1 | +| EIP-8037 | `state_gas_sstore` | 1 | +| EIP-7928 | `block_access_lists_eip7702` | 8 | +| EIP-7928 | `block_access_lists` | 1 | +| EIP-7778 | `gas_accounting` | 3 | +| EIP-7708 | `transfer_logs` | 1 | +| EIP-7976 | `refunds` | 1 | +| EIP-1344 | `chainid` (Amsterdam fork-transition fixture) | 1 | +| **Total**| | **74**| + +Re-enable once we either: +- (a) bump fixtures to a snobal-devnet-7 release that locks in the new + accounting; or +- (b) revert the bal-devnet-7-prep subtraction for bal-devnet-6 + compatibility. + +
+ +
+Full test list (74) + +**EIP-7702 โ€” `for_amsterdam/prague/eip7702_set_code_tx/set_code_txs/`** +- `delegation_clearing` +- `delegation_clearing_and_set` +- `delegation_clearing_failing_tx` +- `delegation_clearing_tx_to` +- `eoa_tx_after_set_code` +- `ext_code_on_chain_delegating_set_code` +- `ext_code_on_self_delegating_set_code` +- `ext_code_on_self_set_code` +- `ext_code_on_set_code` +- `many_delegations` +- `nonce_overflow_after_first_authorization` +- `nonce_validity` +- `reset_code` +- `self_code_on_set_code` +- `self_sponsored_set_code` +- `set_code_multiple_valid_authorization_tuples_same_signer_increasing_nonce` +- `set_code_multiple_valid_authorization_tuples_same_signer_increasing_nonce_self_sponsored` +- `set_code_to_log` +- `set_code_to_non_empty_storage_non_zero_nonce` +- `set_code_to_self_destruct` +- `set_code_to_self_destructing_account_deployed_in_same_tx` +- `set_code_to_sstore` +- `set_code_to_sstore_then_sload` +- `set_code_to_system_contract` + +**EIP-7702 โ€” `for_amsterdam/prague/eip7702_set_code_tx/set_code_txs_2/`** +- `call_pointer_to_created_from_create_after_oog_call_again` +- `call_to_precompile_in_pointer_context` +- `contract_storage_to_pointer_with_storage` +- `delegation_replacement_call_previous_contract` +- `double_auth` +- `pointer_measurements` +- `pointer_normal` +- `pointer_reentry` +- `pointer_resets_an_empty_code_account_with_storage` +- `pointer_reverts` +- `pointer_to_pointer` +- `pointer_to_precompile` +- `pointer_to_static` +- `pointer_to_static_reentry` +- `static_to_pointer` + +**EIP-7702 โ€” `for_amsterdam/prague/eip7702_set_code_tx/gas/`** +- `account_warming` + +**EIP-8037 โ€” `for_amsterdam/amsterdam/eip8037_state_creation_gas_cost_increase/state_gas_set_code/`** +- `auth_refund_block_gas_accounting` +- `auth_refund_bypasses_one_fifth_cap` +- `auth_with_calldata_and_access_list` +- `auth_with_multiple_sstores` +- `authorization_exact_state_gas_boundary` +- `authorization_to_precompile_address` +- `authorization_with_sstore` +- `duplicate_signer_authorizations` +- `existing_account_auth_header_gas_used_uses_worst_case` +- `existing_account_refund` +- `existing_account_refund_enables_sstore` +- `existing_auth_with_reverted_execution_preserves_intrinsic` +- `many_authorizations_state_gas` +- `mixed_auths_header_gas_used_uses_worst_case` +- `mixed_new_and_existing_auths` +- `mixed_valid_and_invalid_auths` +- `multi_tx_block_auth_refund_and_sstore` + +**EIP-8037 โ€” `state_gas_pricing/`** +- `auth_state_gas_scales_with_cpsb` + +**EIP-8037 โ€” `state_gas_sstore/`** +- `sstore_state_gas_all_tx_types` + +**EIP-7928 โ€” `for_amsterdam/amsterdam/eip7928_block_level_access_lists/block_access_lists_eip7702/`** +- `bal_7702_delegation_clear` +- `bal_7702_delegation_create` +- `bal_7702_delegation_update` +- `bal_7702_double_auth_reset` +- `bal_7702_double_auth_swap` +- `bal_7702_null_address_delegation_no_code_change` +- `bal_selfdestruct_to_7702_delegation` +- `bal_withdrawal_to_7702_delegation` + +**EIP-7928 โ€” `block_access_lists/`** +- `bal_all_transaction_types` + +**EIP-7778 โ€” `for_amsterdam/amsterdam/eip7778_block_gas_accounting_without_refunds/gas_accounting/`** +- `multiple_refund_types_in_one_tx` +- `simple_gas_accounting` +- `varying_calldata_costs` + +**EIP-7708 โ€” `for_amsterdam/amsterdam/eip7708_eth_transfer_logs/transfer_logs/`** +- `transfer_with_all_tx_types` + +**EIP-7976 โ€” `for_amsterdam/amsterdam/eip7976_increase_calldata_floor_cost/refunds/`** +- `gas_refunds_from_data_floor` + +**EIP-1344 โ€” `for_amsterdam/istanbul/eip1344_chainid/chainid/`** +- `chainid` (Amsterdam fork-transition fixture) + +
diff --git a/docs/roadmaps/forks-roadmap.md b/docs/roadmaps/forks-roadmap.md index 4288ffd30ed..5e098ad3372 100644 --- a/docs/roadmaps/forks-roadmap.md +++ b/docs/roadmaps/forks-roadmap.md @@ -33,12 +33,12 @@ | **2780** | Reduce Intrinsic Transaction Gas | ๐Ÿ”ด Not implemented (21000 โ†’ 4500) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1940) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | | **7904** | General Repricing | ๐Ÿ”ด Not implemented ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1879) | โš ๏ธ PR #9619 (Draft) | ๐Ÿ”ด | CFI | | **7954** | Increase Max Contract Size | ๐Ÿ”ด Not implemented (24KiB โ†’ 32KiB) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/2028) | โš ๏ธ PR #8760 (Draft) | ๐Ÿ”ด | CFI | -| **7976** | Increase Calldata Floor Cost | ๐Ÿ”ด Not implemented ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1942) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | -| **7981** | Increase Access List Cost | ๐Ÿ”ด Not implemented ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1943) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | -| **8037** | State Creation Gas Cost Increase | โœ… Implemented ([#6271] merged, PR [#6216] open) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/2040) | โœ… bal@v5.4.0 | โš ๏ธ PR [#6216] | CFI | +| **7976** | Increase Calldata Floor Cost | โœ… Implemented (PR #6518, bal@v5.7.0) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1942) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | +| **7981** | Increase Access List Cost | โœ… Implemented (PR #6518, bal@v5.7.0) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1943) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | +| **8037** | State Creation Gas Cost Increase | โœ… Implemented (dynamic cpsb, clamp-and-spill, 2D inclusion, same-tx SELFDESTRUCT refund โ€” PR #6518 on bal@v5.7.0) ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/2040) | โœ… bal@v5.4.0 | โš ๏ธ PR [#6216] | CFI | | **8038** | State-Access Gas Cost Update | ๐Ÿ”ด Not implemented ยท [exec-specs tracking](https://github.com/ethereum/execution-specs/issues/1941) | ๐Ÿ”ด | ๐Ÿ”ด | CFI | -> **Priority note:** All core devnet EIPs are merged. EIP-8037 fully implemented with reservoir model, nested revert fixes, and CREATE collision escrow. BAL optimizations shipped: parallel execution ([#6233]), batched reads + parallel state root ([#6227]). bal-devnet-3 tracking PR [#6216] open with bal@v5.4.0 fixtures, Amsterdam consume-engine hive tests in CI. **Up next:** merge PR [#6216], EIP-7954 ([#6214]). Remaining gas repricing EIPs are **low priority** โ€” no other client has started them. Monitor CFI decisions at ACDE calls. +> **Priority note:** All core devnet EIPs are merged. EIP-8037 fully implemented with reservoir model, clamp-and-spill refunds, 2D inclusion check, and same-tx SELFDESTRUCT refund. EIP-7976 + EIP-7981 shipped with bal-devnet-4 rollup. BAL optimizations shipped: parallel execution ([#6233]), batched reads + parallel state root ([#6227]), shadow-recorder missing-entry detection (PR #6518). bal-devnet-4 tracking PR #6518 open with bal@v5.7.0 fixtures, Amsterdam consume-engine hive 1342/1342 passing. **Up next:** merge PR #6518, EIP-7954 ([#6214]). Remaining gas repricing EIPs are **low priority** โ€” no other client has started them. Monitor CFI decisions at ACDE calls. ### Other Amsterdam EIPs diff --git a/test/tests/blockchain/mempool_tests.rs b/test/tests/blockchain/mempool_tests.rs index 9098b2599bd..7b319e8a1da 100644 --- a/test/tests/blockchain/mempool_tests.rs +++ b/test/tests/blockchain/mempool_tests.rs @@ -97,6 +97,57 @@ fn create_transaction_intrinsic_gas() { assert_eq!(intrinsic_gas, expected_gas_cost); } +/// EIP-8037 / bal-devnet-4: Amsterdam CREATE tx intrinsic must match the VM +/// charge, not the legacy `TX_CREATE_GAS_COST = 53000`. The regular portion +/// drops to `TX_GAS_COST + REGULAR_GAS_CREATE = 30000` and a state portion +/// (`STATE_BYTES_PER_NEW_ACCOUNT * cpsb`) is folded in. Mempool admission +/// must return the total so txs whose `gas_limit` is below the VM intrinsic +/// are rejected before they enter the pool, and txs above it aren't +/// spuriously rejected. +#[test] +fn amsterdam_create_intrinsic_matches_vm_dimensions() { + use ethrex_levm::gas_cost::{ + REGULAR_GAS_CREATE, STATE_BYTES_PER_NEW_ACCOUNT, cost_per_state_byte, + }; + + let (mut config, header) = build_basic_config_and_header(true, true); + // Activate Amsterdam at genesis. Intermediate forks must also be active + // so `config.fork(timestamp)` returns Amsterdam, not an earlier variant. + config.cancun_time = Some(0); + config.prague_time = Some(0); + config.osaka_time = Some(0); + config.bpo1_time = Some(0); + config.bpo2_time = Some(0); + config.amsterdam_time = Some(0); + + let tx = Transaction::EIP1559Transaction(EIP1559Transaction { + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: TxKind::Create, + value: U256::zero(), + data: Bytes::default(), + access_list: Default::default(), + ..Default::default() + }); + + let cpsb = cost_per_state_byte(header.gas_limit); + let expected = TX_GAS_COST + REGULAR_GAS_CREATE + STATE_BYTES_PER_NEW_ACCOUNT * cpsb; + + let intrinsic_gas = transaction_intrinsic_gas(&tx, &header, &config).expect("intrinsic gas"); + assert_eq!( + intrinsic_gas, expected, + "Amsterdam CREATE intrinsic must be TX_BASE + REGULAR_GAS_CREATE + \ + STATE_BYTES_PER_NEW_ACCOUNT * cpsb, not the legacy 53000" + ); + // Guard against regression to the legacy 53000 constant. + assert_ne!( + intrinsic_gas, TX_CREATE_GAS_COST, + "Amsterdam CREATE must NOT use legacy TX_CREATE_GAS_COST" + ); +} + #[test] fn transaction_intrinsic_data_gas_pre_istanbul() { let (config, header) = build_basic_config_and_header(false, false); diff --git a/test/tests/blockchain/smoke_tests.rs b/test/tests/blockchain/smoke_tests.rs index 0bf5b6deee7..fcf8f494d0d 100644 --- a/test/tests/blockchain/smoke_tests.rs +++ b/test/tests/blockchain/smoke_tests.rs @@ -180,51 +180,53 @@ async fn test_reorg_from_long_to_short_chain() { } #[tokio::test] -async fn new_head_with_canonical_ancestor_should_skip() { - // Store and genesis +async fn new_head_ancestor_of_finalized_should_skip() { + // Per execution-apis PR 786, the no-reorg skip optimization only applies when the new + // head is a VALID canonical ancestor of the latest known finalized block. Build a chain + // of 3 blocks, finalize block 2, then FCU to block 1 (an ancestor of finalized) and + // assert that the update is skipped. let store = test_store().await; let genesis_header = store.get_block_header(0).unwrap().unwrap(); - let genesis_hash = genesis_header.hash(); - - // Create blockchain let blockchain = Blockchain::default_with_store(store.clone()); - // Add block at height 1. let block_1 = new_block(&store, &genesis_header).await; let hash_1 = block_1.hash(); blockchain .add_block(block_1.clone()) - .expect("Could not add block 1b."); + .expect("Could not add block 1."); - // Add child at height 2. let block_2 = new_block(&store, &block_1.header).await; let hash_2 = block_2.hash(); blockchain .add_block(block_2.clone()) .expect("Could not add block 2."); - assert!(!is_canonical(&store, 1, hash_1).await.unwrap()); - assert!(!is_canonical(&store, 2, hash_2).await.unwrap()); + let block_3 = new_block(&store, &block_2.header).await; + let hash_3 = block_3.hash(); + blockchain + .add_block(block_3.clone()) + .expect("Could not add block 3."); - // Make that chain the canonical one. - apply_fork_choice(&store, hash_2, genesis_hash, genesis_hash) + // Make the chain canonical and finalize block 2. + apply_fork_choice(&store, hash_3, hash_2, hash_2) .await .unwrap(); assert!(is_canonical(&store, 1, hash_1).await.unwrap()); assert!(is_canonical(&store, 2, hash_2).await.unwrap()); + assert!(is_canonical(&store, 3, hash_3).await.unwrap()); + // FCU to block 1 (ancestor of finalized): MUST be skipped. let result = apply_fork_choice(&store, hash_1, hash_1, hash_1).await; - assert!(matches!( result, Err(InvalidForkChoice::NewHeadAlreadyCanonical) )); - // Important blocks should still be the same as before. - assert!(store.get_finalized_block_number().await.unwrap() == Some(0)); - assert!(store.get_safe_block_number().await.unwrap() == Some(0)); - assert!(store.get_latest_block_number().await.unwrap() == 2); + // State must be unchanged after the skip. + assert_eq!(store.get_finalized_block_number().await.unwrap(), Some(2)); + assert_eq!(store.get_safe_block_number().await.unwrap(), Some(2)); + assert_eq!(store.get_latest_block_number().await.unwrap(), 3); } #[tokio::test] @@ -284,6 +286,65 @@ async fn latest_block_number_should_always_be_the_canonical_head() { assert_eq!(latest_canonical_block_hash(&store).await.unwrap(), hash_b); } +#[tokio::test] +async fn unfinalized_reorg_deeper_than_32_is_allowed() { + // Per execution-apis PR 786 point 6, -38006 TooDeepReorg fires when the reorg + // depth exceeds the implementation-specific limit. ethrex defines that limit as + // its state-history retention (REORG_DEPTH_LIMIT = 128), matching the stance of + // Erigon/Nethermind/Besu/geth โ€” the EL trusts the CL's fork choice and only + // rejects when it physically cannot unwind. A 33-block reorg from genesis is + // well under the cap and must succeed. + + let store = test_store().await; + let genesis_header = store.get_block_header(0).unwrap().unwrap(); + let genesis_hash = genesis_header.hash(); + let blockchain = Blockchain::default_with_store(store.clone()); + + // Build canonical chain A: genesis โ†’ A1 โ†’ ... โ†’ A33. + let mut parent = genesis_header.clone(); + let mut chain_a_hashes = Vec::new(); + for _ in 0..33 { + let block = new_block(&store, &parent).await; + parent = block.header.clone(); + chain_a_hashes.push(block.hash()); + blockchain.add_block(block).unwrap(); + } + let head_a = *chain_a_hashes.last().unwrap(); + apply_fork_choice(&store, head_a, genesis_hash, genesis_hash) + .await + .expect("FCU to chain A head should succeed"); + assert!(is_canonical(&store, 33, head_a).await.unwrap()); + + // Build alternate chain B from genesis. `new_block` randomizes fee_recipient and + // beacon_root, so each block hash differs from chain A even at the same height. + let mut parent = genesis_header.clone(); + let mut chain_b_hashes = Vec::new(); + for _ in 0..33 { + let block = new_block(&store, &parent).await; + parent = block.header.clone(); + chain_b_hashes.push(block.hash()); + blockchain.add_block(block).unwrap(); + } + let head_b = *chain_b_hashes.last().unwrap(); + assert_ne!(head_a, head_b); + + // FCU to chain B head: reorg depth = 33, well under REORG_DEPTH_LIMIT (128). + apply_fork_choice(&store, head_b, genesis_hash, genesis_hash) + .await + .expect("33-block unfinalized reorg should be allowed"); + + // Chain B is canonical end-to-end; chain A's 33 blocks are no longer canonical. + assert!(is_canonical(&store, 33, head_b).await.unwrap()); + assert!(!is_canonical(&store, 33, head_a).await.unwrap()); + for (i, hash) in chain_b_hashes.iter().enumerate() { + assert!( + is_canonical(&store, (i + 1) as u64, *hash).await.unwrap(), + "chain B block at height {} should be canonical", + i + 1 + ); + } +} + async fn new_block(store: &Store, parent: &BlockHeader) -> Block { let args = BuildPayloadArgs { parent: parent.hash(), diff --git a/test/tests/levm/eip7708_tests.rs b/test/tests/levm/eip7708_tests.rs index 5a29a4a46da..22f8bace378 100644 --- a/test/tests/levm/eip7708_tests.rs +++ b/test/tests/levm/eip7708_tests.rs @@ -202,6 +202,7 @@ impl TestBuilder { is_privileged: false, fee_token: None, disable_balance_check: false, + is_system_call: false, }; let tx = Transaction::EIP1559Transaction(EIP1559Transaction { diff --git a/test/tests/levm/eip7928_tests.rs b/test/tests/levm/eip7928_tests.rs index 2ff658e0149..64c31bdd45e 100644 --- a/test/tests/levm/eip7928_tests.rs +++ b/test/tests/levm/eip7928_tests.rs @@ -381,7 +381,7 @@ fn test_block_access_index_semantics() { assert_eq!(alice.storage_changes.len(), 3); // Verify indices are correctly assigned - let indices: Vec = alice + let indices: Vec = alice .storage_changes .iter() .flat_map(|s| s.slot_changes.iter().map(|c| c.block_access_index)) @@ -456,6 +456,35 @@ fn test_code_change_rlp_roundtrip() { assert_eq!(change, decoded); } +/// EIP-7928 widened `BlockAccessIndex` from `uint16` to `uint32`. Round-trip +/// each change variant at an index above `u16::MAX` to guard against an +/// accidental revert to the old narrower type (would silently truncate +/// indices for blocks with > 65535 slots referenced). +#[test] +fn test_change_variants_rlp_roundtrip_index_above_u16_max() { + use ethrex_rlp::{decode::RLPDecode, encode::RLPEncode}; + let idx: u32 = 70_000; + assert!(idx > u32::from(u16::MAX)); + + let storage = StorageChange::new(idx, U256::from(0xdead_beef_u64)); + assert_eq!( + StorageChange::decode(&storage.encode_to_vec()).unwrap(), + storage + ); + + let balance = BalanceChange::new(idx, U256::from(1u64) << 128); + assert_eq!( + BalanceChange::decode(&balance.encode_to_vec()).unwrap(), + balance + ); + + let nonce = NonceChange::new(idx, u64::MAX); + assert_eq!(NonceChange::decode(&nonce.encode_to_vec()).unwrap(), nonce); + + let code = CodeChange::new(idx, bytes::Bytes::from_static(&[0xde, 0xad])); + assert_eq!(CodeChange::decode(&code.encode_to_vec()).unwrap(), code); +} + // ==================== RLP Encoding Hex Validation Tests ==================== // These tests verify specific RLP hex encodings for cross-implementation compatibility @@ -1148,3 +1177,47 @@ fn test_build_filters_reads_that_exist_in_writes() { ); bal.validate_ordering().unwrap(); } + +// ==================== EIP-7928 u32 widening round-trip tests ==================== +// These tests prove the index type is truly u32 by using a value > u16::MAX (65535). + +const WIDE_IDX: u32 = u32::MAX / 2; // 2_147_483_647 โ€” far beyond u16::MAX + +#[test] +fn test_storage_change_u32_index_rlp_roundtrip() { + let original = StorageChange::new(WIDE_IDX, U256::from(0xdeadbeef_u64)); + let encoded = original.encode_to_vec(); + let decoded = StorageChange::decode(&encoded).expect("decode StorageChange"); + assert_eq!(original, decoded); + assert_eq!(decoded.block_access_index, WIDE_IDX); +} + +#[test] +fn test_balance_change_u32_index_rlp_roundtrip() { + let original = BalanceChange::new(WIDE_IDX, U256::from(999_999_u64)); + let encoded = original.encode_to_vec(); + let decoded = BalanceChange::decode(&encoded).expect("decode BalanceChange"); + assert_eq!(original, decoded); + assert_eq!(decoded.block_access_index, WIDE_IDX); +} + +#[test] +fn test_nonce_change_u32_index_rlp_roundtrip() { + let original = NonceChange::new(WIDE_IDX, 42); + let encoded = original.encode_to_vec(); + let decoded = NonceChange::decode(&encoded).expect("decode NonceChange"); + assert_eq!(original, decoded); + assert_eq!(decoded.block_access_index, WIDE_IDX); +} + +#[test] +fn test_code_change_u32_index_rlp_roundtrip() { + let original = CodeChange::new( + WIDE_IDX, + bytes::Bytes::from_static(&[0x60, 0x00, 0x60, 0x00]), + ); + let encoded = original.encode_to_vec(); + let decoded = CodeChange::decode(&encoded).expect("decode CodeChange"); + assert_eq!(original, decoded); + assert_eq!(decoded.block_access_index, WIDE_IDX); +} diff --git a/test/tests/levm/eip8037_tests.rs b/test/tests/levm/eip8037_tests.rs new file mode 100644 index 00000000000..39d34277310 --- /dev/null +++ b/test/tests/levm/eip8037_tests.rs @@ -0,0 +1,223 @@ +//! EIP-8037 intrinsic-gas parity tests. +//! +//! Covers parity between the standalone `intrinsic_gas_dimensions` helper +//! (used by mempool / payload builder) and `VM::get_intrinsic_gas` (used +//! during actual tx execution). They must agree on every tx shape or mempool +//! admission will drift from VM charge. + +use bytes::Bytes; +use ethrex_common::{ + Address, H256, U256, + types::{ + Account, AccountState, AuthorizationTuple, ChainConfig, Code, CodeMetadata, + EIP1559Transaction, EIP7702Transaction, Fork, Transaction, TxKind, + }, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::{ + db::{Database, gen_db::GeneralizedDatabase}, + environment::{EVMConfig, Environment}, + errors::DatabaseError, + tracing::LevmCallTracer, + utils::intrinsic_gas_dimensions, + vm::{VM, VMType}, +}; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +struct TestDb; + +impl Database for TestDb { + fn get_account_state(&self, _address: Address) -> Result { + Ok(AccountState::default()) + } + fn get_storage_value(&self, _address: Address, _key: H256) -> Result { + Ok(U256::zero()) + } + fn get_block_hash(&self, _block_number: u64) -> Result { + Ok(H256::zero()) + } + fn get_chain_config(&self) -> Result { + Ok(ChainConfig::default()) + } + fn get_account_code(&self, _code_hash: H256) -> Result { + Ok(Code::default()) + } + fn get_code_metadata(&self, _code_hash: H256) -> Result { + Ok(CodeMetadata { length: 0 }) + } +} + +fn parity_db() -> GeneralizedDatabase { + let mut accounts: FxHashMap = FxHashMap::default(); + accounts.insert( + Address::from_low_u64_be(0x1000), + Account::new( + U256::from(10u64).pow(18.into()), + Code::default(), + 0, + FxHashMap::default(), + ), + ); + GeneralizedDatabase::new_with_account_state(Arc::new(TestDb), accounts) +} + +fn parity_env(fork: Fork, block_gas_limit: u64) -> Environment { + let blob_schedule = EVMConfig::canonical_values(fork); + Environment { + origin: Address::from_low_u64_be(0x1000), + gas_limit: 1_000_000, + config: EVMConfig::new(fork, blob_schedule), + block_number: 1, + coinbase: Address::from_low_u64_be(0xCCC), + timestamp: 1000, + prev_randao: Some(H256::zero()), + difficulty: U256::zero(), + slot_number: U256::zero(), + chain_id: U256::from(1), + base_fee_per_gas: U256::zero(), + base_blob_fee_per_gas: U256::from(1), + gas_price: U256::zero(), + block_excess_blob_gas: None, + block_blob_gas_used: None, + tx_blob_hashes: vec![], + tx_max_priority_fee_per_gas: None, + tx_max_fee_per_gas: Some(U256::zero()), + tx_max_fee_per_blob_gas: None, + tx_nonce: 0, + block_gas_limit, + is_privileged: false, + fee_token: None, + disable_balance_check: true, + is_system_call: false, + } +} + +/// Asserts `intrinsic_gas_dimensions(tx, fork, block_gas_limit)` and +/// `VM::new(env, ...).get_intrinsic_gas()` return the same `(regular, state)` +/// split. A divergence means mempool admission would drift from VM charge. +fn assert_parity(fork: Fork, block_gas_limit: u64, tx: &Transaction) { + let standalone = + intrinsic_gas_dimensions(tx, fork, block_gas_limit).expect("intrinsic_gas_dimensions"); + + let env = parity_env(fork, block_gas_limit); + let mut db = parity_db(); + let vm = VM::new( + env, + &mut db, + tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .expect("VM::new"); + let from_vm = vm.get_intrinsic_gas().expect("get_intrinsic_gas"); + + assert_eq!( + standalone, from_vm, + "intrinsic_gas_dimensions and VM::get_intrinsic_gas diverged for fork {fork:?}: \ + standalone={standalone:?}, vm={from_vm:?}" + ); +} + +#[test] +fn test_intrinsic_parity_plain_transfer() { + let tx = Transaction::EIP1559Transaction(EIP1559Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: TxKind::Call(Address::from_low_u64_be(0xBEEF)), + value: U256::from(1u64), + data: Bytes::new(), + access_list: Default::default(), + ..Default::default() + }); + // Parity across multiple forks to catch fork-gating regressions too. + for fork in [Fork::Prague, Fork::Osaka, Fork::Amsterdam] { + assert_parity(fork, 30_000_000, &tx); + assert_parity(fork, 120_000_000, &tx); + } +} + +#[test] +fn test_intrinsic_parity_create_tx() { + let tx = Transaction::EIP1559Transaction(EIP1559Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: TxKind::Create, + value: U256::zero(), + data: Bytes::from(vec![0x60u8, 0x00, 0x60, 0x00, 0xF3]), + access_list: Default::default(), + ..Default::default() + }); + for fork in [Fork::Prague, Fork::Osaka, Fork::Amsterdam] { + assert_parity(fork, 30_000_000, &tx); + assert_parity(fork, 120_000_000, &tx); + } +} + +#[test] +fn test_intrinsic_parity_with_calldata_and_access_list() { + let tx = Transaction::EIP1559Transaction(EIP1559Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: TxKind::Call(Address::from_low_u64_be(0xBEEF)), + value: U256::zero(), + // Mix zero + non-zero bytes to exercise EIP-2028 weighted calldata + // AND the EIP-7976 unweighted floor path. + data: Bytes::from(vec![0u8, 1, 0, 2, 0, 3, 4, 5, 0, 0]), + access_list: vec![ + ( + Address::from_low_u64_be(0x11), + vec![H256::from_low_u64_be(1), H256::from_low_u64_be(2)], + ), + ( + Address::from_low_u64_be(0x22), + vec![H256::from_low_u64_be(3)], + ), + ], + ..Default::default() + }); + for fork in [Fork::Prague, Fork::Osaka, Fork::Amsterdam] { + assert_parity(fork, 30_000_000, &tx); + assert_parity(fork, 120_000_000, &tx); + } +} + +#[test] +fn test_intrinsic_parity_eip7702_auth_list() { + // Dummy authorization tuple โ€” only the count matters for intrinsic gas. + let auth = AuthorizationTuple { + chain_id: U256::from(1), + address: Address::from_low_u64_be(0xAA), + nonce: 0, + y_parity: U256::zero(), + r_signature: U256::from(1), + s_signature: U256::from(1), + }; + let tx = Transaction::EIP7702Transaction(EIP7702Transaction { + chain_id: 1, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 1_000_000, + to: Address::from_low_u64_be(0xBEEF), + value: U256::zero(), + data: Bytes::new(), + access_list: Default::default(), + authorization_list: vec![auth, auth], + ..Default::default() + }); + for fork in [Fork::Prague, Fork::Osaka, Fork::Amsterdam] { + assert_parity(fork, 30_000_000, &tx); + assert_parity(fork, 120_000_000, &tx); + } +} diff --git a/test/tests/levm/eip8037_top_level_failure_tests.rs b/test/tests/levm/eip8037_top_level_failure_tests.rs new file mode 100644 index 00000000000..7850eb21279 --- /dev/null +++ b/test/tests/levm/eip8037_top_level_failure_tests.rs @@ -0,0 +1,360 @@ +//! EIP-8037 top-level reservoir reset โ€” ethrex-specific divergence guards. +//! +//! The general top-level failure semantics (revert/halt/OOG refund execution +//! state gas, zero block-level state gas, CREATE-tx intrinsic survives, etc.) +//! are covered by `tests/amsterdam/eip8037_state_creation_gas_cost_increase/ +//! test_state_gas_reservoir.py` in EELS and run via the blockchain ef-tests. +//! +//! The two tests below stay because they assert ethrex-only invariants that +//! ef-tests cannot express: they pin the block-level `gas_used` on halt paths +//! where ethrex's `max(spill_outstanding, reservoir_surplus)` reclassification +//! formula previously drifted from EELS by exactly one state-gas charge. + +use bytes::Bytes; +use ethrex_common::{ + Address, H256, U256, + constants::EMPTY_TRIE_HASH, + types::{ + Account, AccountState, ChainConfig, Code, CodeMetadata, EIP1559Transaction, Fork, + Transaction, TxKind, + }, +}; +use ethrex_crypto::NativeCrypto; +use ethrex_levm::{ + db::{Database, gen_db::GeneralizedDatabase}, + environment::{EVMConfig, Environment}, + errors::{DatabaseError, ExecutionReport}, + gas_cost::{STATE_BYTES_PER_STORAGE_SET, cost_per_state_byte}, + tracing::LevmCallTracer, + vm::{VM, VMType}, +}; +use rustc_hash::FxHashMap; +use std::sync::Arc; + +// ==================== Test Database ==================== + +struct TestDatabase { + accounts: FxHashMap, +} + +impl TestDatabase { + fn new() -> Self { + Self { + accounts: FxHashMap::default(), + } + } +} + +impl Database for TestDatabase { + fn get_account_state(&self, address: Address) -> Result { + Ok(self + .accounts + .get(&address) + .map(|acc| AccountState { + nonce: acc.info.nonce, + balance: acc.info.balance, + storage_root: *EMPTY_TRIE_HASH, + code_hash: acc.info.code_hash, + }) + .unwrap_or_default()) + } + + fn get_storage_value(&self, address: Address, key: H256) -> Result { + Ok(self + .accounts + .get(&address) + .and_then(|acc| acc.storage.get(&key).copied()) + .unwrap_or_default()) + } + + fn get_block_hash(&self, _block_number: u64) -> Result { + Ok(H256::zero()) + } + + fn get_chain_config(&self) -> Result { + Ok(ChainConfig::default()) + } + + fn get_account_code(&self, code_hash: H256) -> Result { + for acc in self.accounts.values() { + if acc.info.code_hash == code_hash { + return Ok(acc.code.clone()); + } + } + Ok(Code::default()) + } + + fn get_code_metadata(&self, code_hash: H256) -> Result { + for acc in self.accounts.values() { + if acc.info.code_hash == code_hash { + return Ok(CodeMetadata { + length: acc.code.bytecode.len() as u64, + }); + } + } + Ok(CodeMetadata { length: 0 }) + } +} + +// ==================== Constants ==================== + +const SENDER: u64 = 0x1000; +const CONTRACT_A: u64 = 0x2000; +const GAS_LIMIT: u64 = 500_000; + +// ==================== Bytecode helpers ==================== + +/// PUSH1 value, PUSH1 slot, SSTORE +fn sstore_byte(slot: u8, value: u8) -> Vec { + vec![0x60, value, 0x60, slot, 0x55] +} + +/// INVALID (0xfe) โ€” causes exceptional halt +fn invalid_bytecode() -> Vec { + vec![0xfe] +} + +/// Inline CREATE-with-failing-initcode bytecode. +fn create_failing_bytecode(initcode_byte: u8) -> Vec { + vec![ + 0x60, + initcode_byte, // PUSH1 + 0x60, + 0x00, // PUSH1 0 + 0x53, // MSTORE8 โ€” memory[0] = byte + 0x60, + 0x01, // PUSH1 1 (size) + 0x60, + 0x00, // PUSH1 0 (offset) + 0x60, + 0x00, // PUSH1 0 (value) + 0xf0, // CREATE + 0x50, // POP + ] +} + +// ==================== Test runner ==================== + +fn eoa(balance: U256) -> Account { + Account::new(balance, Code::default(), 0, FxHashMap::default()) +} + +fn contract(code: Vec) -> Account { + Account::new( + U256::zero(), + Code::from_bytecode(Bytes::from(code), &NativeCrypto), + 1, + FxHashMap::default(), + ) +} + +struct TestRunner { + accounts: Vec<(Address, Account)>, + target: Address, + is_create: bool, + calldata: Bytes, +} + +impl TestRunner { + fn call(target: Address) -> Self { + Self { + accounts: Vec::new(), + target, + is_create: false, + calldata: Bytes::new(), + } + } + + fn create(initcode: Vec) -> Self { + Self { + accounts: Vec::new(), + target: Address::default(), + is_create: true, + calldata: Bytes::from(initcode), + } + } + + fn with_account(mut self, addr: Address, acc: Account) -> Self { + self.accounts.push((addr, acc)); + self + } + + fn run(self) -> ExecutionReport { + let gas_limit = GAS_LIMIT; + let block_gas_limit = GAS_LIMIT * 2; + let test_db = TestDatabase::new(); + let accounts_map: FxHashMap = self.accounts.into_iter().collect(); + let mut db = GeneralizedDatabase::new_with_account_state(Arc::new(test_db), accounts_map); + + let fork = Fork::Amsterdam; + let blob_schedule = EVMConfig::canonical_values(fork); + let env = Environment { + origin: Address::from_low_u64_be(SENDER), + gas_limit, + config: EVMConfig::new(fork, blob_schedule), + block_number: 1, + coinbase: Address::from_low_u64_be(0xCCC), + timestamp: 1000, + prev_randao: Some(H256::zero()), + difficulty: U256::zero(), + slot_number: U256::zero(), + chain_id: U256::from(1), + base_fee_per_gas: U256::zero(), + base_blob_fee_per_gas: U256::from(1), + gas_price: U256::zero(), + block_excess_blob_gas: None, + block_blob_gas_used: None, + tx_blob_hashes: vec![], + tx_max_priority_fee_per_gas: None, + tx_max_fee_per_gas: Some(U256::zero()), + tx_max_fee_per_blob_gas: None, + tx_nonce: 0, + block_gas_limit, + is_privileged: false, + fee_token: None, + disable_balance_check: true, + is_system_call: false, + }; + + let tx = if self.is_create { + Transaction::EIP1559Transaction(EIP1559Transaction { + to: TxKind::Create, + value: U256::zero(), + data: self.calldata, + gas_limit, + max_fee_per_gas: 0, + max_priority_fee_per_gas: 0, + ..Default::default() + }) + } else { + Transaction::EIP1559Transaction(EIP1559Transaction { + to: TxKind::Call(self.target), + value: U256::zero(), + data: Bytes::new(), + gas_limit, + max_fee_per_gas: 0, + max_priority_fee_per_gas: 0, + ..Default::default() + }) + }; + + let mut vm = VM::new( + env, + &mut db, + &tx, + LevmCallTracer::disabled(), + VMType::L1, + &NativeCrypto, + ) + .unwrap(); + vm.execute().unwrap() + } +} + +// ==================== Test: partial credit-to-spill diverges from EELS ==================== + +/// Top-level halt after an SSTORE charge (spilled, since reservoir = 0) and a +/// failed inner CREATE. Pins `report.gas_used` to the EELS reference value so +/// that ethrex's halt-reclassification formula stays aligned. +/// +/// Per EELS `total_state - reservoir`, every byte of charged state-gas burned +/// by the halt must surface in the regular dimension; the pre-fix ethrex +/// formula was dropping the residual outstanding spill that the credit didn't +/// cancel, producing `gas_limit - SSTORE_STATE` instead of `gas_limit`. +#[test] +fn test_top_halt_after_partial_credit_to_spill_diverges_from_eels() { + use ethrex_levm::gas_cost::STATE_BYTES_PER_NEW_ACCOUNT; + + let addr_a = Address::from_low_u64_be(CONTRACT_A); + + // SSTORE(slot 0 = 5); CREATE(failing initcode); INVALID + let mut code = sstore_byte(0, 5); + code.extend(create_failing_bytecode(0xfe)); + code.extend(invalid_bytecode()); + + let report = TestRunner::call(addr_a) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .with_account(addr_a, contract(code)) + .run(); + + assert!( + !report.is_success(), + "tx should halt on INVALID: {:?}", + report.result + ); + + // Plain CALL tx: intrinsic_state_gas = 0 โ†’ state dimension wipes to 0 on top-level failure. + assert_eq!( + report.state_gas_used, 0, + "block state_gas_used should be 0 for a top-level halted plain CALL tx" + ); + + let cpsb = cost_per_state_byte(GAS_LIMIT * 2); + let _state_new = STATE_BYTES_PER_NEW_ACCOUNT * cpsb; + let sstore_state = STATE_BYTES_PER_STORAGE_SET * cpsb; + let expected_gas_used_eels = GAS_LIMIT; + + assert!( + sstore_state > 0, + "test scenario requires nonzero SSTORE state-gas to leave residual spill after credit" + ); + + assert_eq!( + report.gas_used, + expected_gas_used_eels, + "block gas_used divergence: ethrex={} expected_eels={} diff={} (== one SSTORE state-gas charge); \ + ethrex's `max(spill_outstanding, reservoir_surplus)` halt formula drops the \ + residual outstanding spill that wasn't cancelled by the CREATE-failure refund", + report.gas_used, + expected_gas_used_eels, + expected_gas_used_eels.saturating_sub(report.gas_used), + ); +} + +// ==================== Test: phantom drain credit must not cancel real spill ==================== + +/// Regression for the bal-devnet-6 block-21 fork between ethrex and geth on a +/// CREATE TX whose initcode performs two failing inner CREATEs. +/// +/// Asserts the phantom-drain-credit from refunding the reservoir-funded second +/// inner CREATE does not cancel the real spill from the first inner CREATE. +/// Pre-fix ethrex reported `gas_limit - STATE_NEW` instead of `gas_limit`. +#[test] +fn test_top_halt_phantom_drain_does_not_cancel_real_spill() { + use ethrex_levm::gas_cost::STATE_BYTES_PER_NEW_ACCOUNT; + + let mut initcode = create_failing_bytecode(0xfe); + initcode.extend(create_failing_bytecode(0xfe)); + initcode.extend(invalid_bytecode()); + + let report = TestRunner::create(initcode) + .with_account(Address::from_low_u64_be(SENDER), eoa(U256::from(1_000_000))) + .run(); + + assert!( + !report.is_success(), + "CREATE tx should halt on INVALID: {:?}", + report.result + ); + + // CREATE tx: intrinsic_state_gas = STATE_NEW; survives top-level wipe. + let cpsb = cost_per_state_byte(GAS_LIMIT * 2); + let state_new = STATE_BYTES_PER_NEW_ACCOUNT * cpsb; + assert_eq!( + report.state_gas_used, state_new, + "block state_gas_used should equal intrinsic_state (one NEW_ACCOUNT) for a halted CREATE tx" + ); + + let expected_gas_used_eels = GAS_LIMIT; + + assert_eq!( + report.gas_used, + expected_gas_used_eels, + "block gas_used divergence: ethrex={} expected_eels={} diff={} (== one NEW_ACCOUNT state-gas charge); \ + the phantom drain credit from refunding the reservoir-funded second inner CREATE \ + must not cancel the real spill from the first inner CREATE", + report.gas_used, + expected_gas_used_eels, + expected_gas_used_eels.saturating_sub(report.gas_used), + ); +} diff --git a/test/tests/levm/l2_fee_token_tests.rs b/test/tests/levm/l2_fee_token_tests.rs index 226851ea81a..21a9b551918 100644 --- a/test/tests/levm/l2_fee_token_tests.rs +++ b/test/tests/levm/l2_fee_token_tests.rs @@ -185,6 +185,7 @@ fn fee_token_lock_reverted_on_validation_failure() { is_privileged: false, fee_token: Some(fee_token), disable_balance_check: false, + is_system_call: false, }; let tx = Transaction::EIP1559Transaction(EIP1559Transaction { diff --git a/test/tests/levm/l2_gas_reservation_tests.rs b/test/tests/levm/l2_gas_reservation_tests.rs index ee55066dfdd..87fafcea34f 100644 --- a/test/tests/levm/l2_gas_reservation_tests.rs +++ b/test/tests/levm/l2_gas_reservation_tests.rs @@ -156,6 +156,7 @@ fn make_env(gas_limit: u64) -> Environment { is_privileged: false, fee_token: None, disable_balance_check: false, + is_system_call: false, } } diff --git a/test/tests/levm/l2_hook_tests.rs b/test/tests/levm/l2_hook_tests.rs index 20f655f4b62..03351e14990 100644 --- a/test/tests/levm/l2_hook_tests.rs +++ b/test/tests/levm/l2_hook_tests.rs @@ -238,6 +238,7 @@ fn fee_token_storage_rolled_back_on_validation_failure() { is_privileged: false, fee_token: Some(fee_token_addr), disable_balance_check: false, + is_system_call: false, }; let fee_config = FeeConfig { @@ -444,6 +445,7 @@ fn fee_token_revert_during_finalize_triggers_rollback() { is_privileged: false, fee_token: Some(fee_token_addr), disable_balance_check: false, + is_system_call: false, }; let fee_config = FeeConfig { @@ -551,6 +553,7 @@ fn privileged_tx_intrinsic_gas_failure_preserves_sender_balance() { is_privileged: true, fee_token: None, disable_balance_check: false, + is_system_call: false, }; let tx = Transaction::PrivilegedL2Transaction(PrivilegedL2Transaction { diff --git a/test/tests/levm/mod.rs b/test/tests/levm/mod.rs index 55b2325127e..1d007c2462c 100644 --- a/test/tests/levm/mod.rs +++ b/test/tests/levm/mod.rs @@ -5,6 +5,8 @@ mod eip7702_tests; mod eip7708_tests; mod eip7778_tests; mod eip7928_tests; +mod eip8037_tests; +mod eip8037_top_level_failure_tests; mod l2_fee_token_ratio_tests; mod l2_fee_token_tests; mod l2_gas_reservation_tests; diff --git a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam index 2290401371e..78577b44c5f 100644 --- a/tooling/ef_tests/blockchain/.fixtures_url_amsterdam +++ b/tooling/ef_tests/blockchain/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v5.6.1/fixtures_bal.tar.gz +https://github.com/ethereum/execution-spec-tests/releases/download/snobal-devnet-6%40v1.1.0/fixtures_snobal-devnet-6.tar.gz diff --git a/tooling/ef_tests/blockchain/Makefile b/tooling/ef_tests/blockchain/Makefile index 7557bd5b2a4..8528ad884a2 100644 --- a/tooling/ef_tests/blockchain/Makefile +++ b/tooling/ef_tests/blockchain/Makefile @@ -16,6 +16,12 @@ AMSTERDAM_FIXTURES_FILE := .fixtures_url_amsterdam AMSTERDAM_ARTIFACT := amsterdam-tests.tar.gz AMSTERDAM_URL := $(shell cat $(AMSTERDAM_FIXTURES_FILE)) +# zkevm@v0.3.3 ships fixtures filled against an older Amsterdam base +# (bal@v5.6.1). Extracting them on top of the snobal-devnet-6 tree would +# clobber the newer fixtures with stale gas-accounting expectations, so we +# keep them in a separate root and only the stateless harness reads from it. +ZKEVM_VECTORS_ROOT := vectors_zkevm +ZKEVM_VECTORS_DIR := $(ZKEVM_VECTORS_ROOT)/eest ZKEVM_FIXTURES_FILE := .fixtures_url_zkevm ZKEVM_ARTIFACT := zkevm-tests.tar.gz ZKEVM_URL := $(shell cat $(ZKEVM_FIXTURES_FILE)) @@ -50,9 +56,10 @@ amsterdam-vectors: $(AMSTERDAM_ARTIFACT) $(SPECTEST_VECTORS_DIR) $(ZKEVM_ARTIFACT): $(ZKEVM_FIXTURES_FILE) curl -L -o $(ZKEVM_ARTIFACT) $(ZKEVM_URL) -# amsterdam-vectors must run first so witness-bearing zkevm JSONs overlay the bal@v5.6.1 copies. -zkevm-vectors: $(ZKEVM_ARTIFACT) $(SPECTEST_VECTORS_DIR) amsterdam-vectors - tar -xzf $(ZKEVM_ARTIFACT) --strip-components=2 -C $(SPECTEST_VECTORS_DIR) fixtures/blockchain_tests/for_amsterdam +zkevm-vectors: $(ZKEVM_ARTIFACT) + rm -rf $(ZKEVM_VECTORS_DIR) + mkdir -p $(ZKEVM_VECTORS_DIR) + tar -xzf $(ZKEVM_ARTIFACT) --strip-components=2 -C $(ZKEVM_VECTORS_DIR) fixtures/blockchain_tests/for_amsterdam help: ## ๐Ÿ“š Show help for each of the Makefile recipes @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @@ -60,21 +67,28 @@ help: ## ๐Ÿ“š Show help for each of the Makefile recipes download-test-vectors: $(VECTORS_TARGETS) amsterdam-vectors zkevm-vectors ## ๐Ÿ“ฅ Download test vectors clean-vectors: ## ๐Ÿ—‘๏ธ Clean test vectors - rm -rf $(VECTORS_ROOT) + rm -rf $(VECTORS_ROOT) $(ZKEVM_VECTORS_ROOT) rm -f $(SPECTEST_ARTIFACT) $(LEGACYTEST_ARTIFACT) $(AMSTERDAM_ARTIFACT) $(ZKEVM_ARTIFACT) -test-levm: $(VECTORS_TARGETS) amsterdam-vectors zkevm-vectors ## ๐Ÿงช Run blockchain tests with LEVM +test-levm: $(VECTORS_TARGETS) amsterdam-vectors ## ๐Ÿงช Run blockchain tests with LEVM cargo test --profile release-with-debug -test-sp1: $(VECTORS_TARGETS) amsterdam-vectors zkevm-vectors +test-sp1: $(VECTORS_TARGETS) amsterdam-vectors cargo test --profile release-with-debug --features sp1 -test-stateless: $(VECTORS_TARGETS) amsterdam-vectors zkevm-vectors +test-stateless: zkevm-vectors cargo test --profile release-with-debug --features stateless -test-stateless-zkevm: $(VECTORS_TARGETS) amsterdam-vectors zkevm-vectors +test-stateless-zkevm: zkevm-vectors cargo test --profile release-with-debug --features stateless -- eip8025_optional_proofs -test: ## ๐Ÿงช Run blockchain tests with LEVM both with state and stateless +test: ## ๐Ÿงช Run blockchain tests with LEVM both with state and stateless $(MAKE) test-levm - $(MAKE) test-stateless + # Narrow stateless coverage to the EIP-8025 optional-proofs suite. The + # zkevm@v0.3.3 fixtures are filled against bal@v5.6.1, which is out of + # sync with this branch's bal-devnet-6+ (and bal-devnet-7-prep) gas + # accounting; the broader `test-stateless` invocation introduced by + # #6527 trips ~549 of those fixtures with `GasUsedMismatch` / + # `ReceiptsRootMismatch` / `BlockAccessListHashMismatch`. Re-broaden + # once the zkevm bundle is regenerated against the current bal spec. + $(MAKE) test-stateless-zkevm diff --git a/tooling/ef_tests/blockchain/test_runner.rs b/tooling/ef_tests/blockchain/test_runner.rs index ff065b3a747..a0d48702061 100644 --- a/tooling/ef_tests/blockchain/test_runner.rs +++ b/tooling/ef_tests/blockchain/test_runner.rs @@ -158,8 +158,12 @@ async fn run( "Warning: Returned exception {error:?} does not match expected {expected_exception:?}", ); } - // Expected exception matched โ€” stop processing further blocks of this test. - break; + // Expected exception matched โ€” block was rejected, but the test may + // still expect subsequent blocks to be processed (e.g. fork-transition + // tests where a block at the pre-fork timestamp fails and a block at + // the post-fork timestamp succeeds, both built on the same parent). + // Continue with the next block in the fixture. + continue; } Ok(_) => { if expects_exception { diff --git a/tooling/ef_tests/blockchain/tests/all.rs b/tooling/ef_tests/blockchain/tests/all.rs index 31c872585e2..95b19996afd 100644 --- a/tooling/ef_tests/blockchain/tests/all.rs +++ b/tooling/ef_tests/blockchain/tests/all.rs @@ -6,6 +6,13 @@ use std::path::Path; #[cfg(all(feature = "sp1", feature = "stateless"))] compile_error!("Only one of `sp1` and `stateless` can be enabled at a time."); +// test-levm / test-sp1 read snobal-devnet-6 + legacy from `vectors/`. +// test-stateless reads zkevm@v0.3.3 (the only bundle that ships executionWitness) +// from a separate `vectors_zkevm/` so its older bal@v5.6.1 base never overlays +// the snobal fixtures used by the other suites. +#[cfg(feature = "stateless")] +const TEST_FOLDER: &str = "vectors_zkevm/"; +#[cfg(not(feature = "stateless"))] const TEST_FOLDER: &str = "vectors/"; // Base skips shared by all runs. @@ -18,6 +25,140 @@ const SKIPPED_BASE: &[&str] = &[ "ValueOverflowParis", // Skip because it's a "Create" Blob Transaction, which doesn't actually exist. It never reaches the EVM because we can't even parse it as an actual Transaction. "createBlobhashTx", + // EIP-8025 optional-proofs fixtures filled against bal@v5.6.1 (devnets/bal/3), + // which predates EELS PR #2711 "immutable intrinsic_state_gas for EIP-7702". + // Expected gas assumes the auth refund still deducts from block-accounted state + // gas; our devnet-4 (bal@v5.7.0) impl correctly keeps intrinsic_state_gas + // immutable and routes the refund to the reservoir only. Re-enable once the + // zkevm@v0.4.x release ships fixtures regenerated against devnet-4. + "witness_codes_redelegation_old_marker_included_new_marker_excluded", + "witness_codes_reset_delegation", + "witness_codes_reverted_transaction", + "witness_codes_failed_create_includes_factory", + "witness_codes_reverted_create_same_hash_then_read", + "witness_codes_create_then_selfdestruct_same_tx", + // --------------------------------------------------------------- + // bal-devnet-6 known-failing fixtures (Amsterdam fork only). + // + // All entries below are anchored with `[fork_Amsterdam` so the legacy + // Prague/Osaka variants of the same EELS test functions still run (those + // pass). Each bucket maps to one EIP / fixture family; the underlying + // root cause is that snobal-devnet-6 fixtures expect the + // bal-devnet-6 spec semantics, but our impl currently runs ahead of + // that on the EIP-7702 `set_delegation` state-gas accounting (the + // bal-devnet-7-prep SELFDESTRUCT-style refund subtraction was re-applied + // in 0976534cf0). To be re-enabled once we either: + // (a) bump fixtures to a snobal-devnet-7 release that locks in the + // new accounting, or + // (b) revert the bal-devnet-7-prep subtraction for bal-devnet-6 + // compatibility. + // Tracking via PR #6574. + // --------------------------------------------------------------- + + // EIP-7702 โ€” for_amsterdam/prague/eip7702_set_code_tx/set_code_txs/*. + // Prague set-code transaction tests re-run under Amsterdam; expected gas + // accounting differs from current set_delegation refund handling. + "test_delegation_clearing[fork_Amsterdam", + "test_delegation_clearing_and_set[fork_Amsterdam", + "test_delegation_clearing_failing_tx[fork_Amsterdam", + "test_delegation_clearing_tx_to[fork_Amsterdam", + "test_eoa_tx_after_set_code[fork_Amsterdam", + "test_ext_code_on_chain_delegating_set_code[fork_Amsterdam", + "test_ext_code_on_self_delegating_set_code[fork_Amsterdam", + "test_ext_code_on_self_set_code[fork_Amsterdam", + "test_ext_code_on_set_code[fork_Amsterdam", + "test_many_delegations[fork_Amsterdam", + "test_nonce_overflow_after_first_authorization[fork_Amsterdam", + "test_nonce_validity[fork_Amsterdam", + "test_reset_code[fork_Amsterdam", + "test_self_code_on_set_code[fork_Amsterdam", + "test_self_sponsored_set_code[fork_Amsterdam", + "test_set_code_multiple_valid_authorization_tuples_same_signer_increasing_nonce[fork_Amsterdam", + "test_set_code_multiple_valid_authorization_tuples_same_signer_increasing_nonce_self_sponsored[fork_Amsterdam", + "test_set_code_to_log[fork_Amsterdam", + "test_set_code_to_non_empty_storage_non_zero_nonce[fork_Amsterdam", + "test_set_code_to_self_destruct[fork_Amsterdam", + "test_set_code_to_self_destructing_account_deployed_in_same_tx[fork_Amsterdam", + "test_set_code_to_sstore[fork_Amsterdam", + "test_set_code_to_sstore_then_sload[fork_Amsterdam", + "test_set_code_to_system_contract[fork_Amsterdam", + // EIP-7702 โ€” for_amsterdam/prague/eip7702_set_code_tx/set_code_txs_2/*. + // 7702-pointer interaction tests; fail for the same `set_delegation` + // accounting reason as the set_code_txs bucket above. + "test_call_pointer_to_created_from_create_after_oog_call_again[fork_Amsterdam", + "test_call_to_precompile_in_pointer_context[fork_Amsterdam", + "test_contract_storage_to_pointer_with_storage[fork_Amsterdam", + "test_delegation_replacement_call_previous_contract[fork_Amsterdam", + "test_double_auth[fork_Amsterdam", + "test_pointer_measurements[fork_Amsterdam", + "test_pointer_normal[fork_Amsterdam", + "test_pointer_reentry[fork_Amsterdam", + "test_pointer_resets_an_empty_code_account_with_storage[fork_Amsterdam", + "test_pointer_reverts[fork_Amsterdam", + "test_pointer_to_pointer[fork_Amsterdam", + "test_pointer_to_precompile[fork_Amsterdam", + "test_pointer_to_static[fork_Amsterdam", + "test_pointer_to_static_reentry[fork_Amsterdam", + "test_static_to_pointer[fork_Amsterdam", + // EIP-7702 โ€” for_amsterdam/prague/eip7702_set_code_tx/gas/*. + "test_account_warming[fork_Amsterdam", + // EIP-8037 โ€” for_amsterdam/amsterdam/eip8037_state_creation_gas_cost_increase/state_gas_set_code/*. + // 2D-gas tests covering the EIP-7702 auth refund path; same root cause + // as the EIP-7702 buckets above. + "test_auth_refund_block_gas_accounting[fork_Amsterdam", + "test_auth_refund_bypasses_one_fifth_cap[fork_Amsterdam", + "test_auth_with_calldata_and_access_list[fork_Amsterdam", + "test_auth_with_multiple_sstores[fork_Amsterdam", + "test_authorization_exact_state_gas_boundary[fork_Amsterdam", + "test_authorization_to_precompile_address[fork_Amsterdam", + "test_authorization_with_sstore[fork_Amsterdam", + "test_duplicate_signer_authorizations[fork_Amsterdam", + "test_existing_account_auth_header_gas_used_uses_worst_case[fork_Amsterdam", + "test_existing_account_refund[fork_Amsterdam", + "test_existing_account_refund_enables_sstore[fork_Amsterdam", + "test_existing_auth_with_reverted_execution_preserves_intrinsic[fork_Amsterdam", + "test_many_authorizations_state_gas[fork_Amsterdam", + "test_mixed_auths_header_gas_used_uses_worst_case[fork_Amsterdam", + "test_mixed_new_and_existing_auths[fork_Amsterdam", + "test_mixed_valid_and_invalid_auths[fork_Amsterdam", + "test_multi_tx_block_auth_refund_and_sstore[fork_Amsterdam", + // EIP-8037 โ€” for_amsterdam/amsterdam/eip8037_state_creation_gas_cost_increase/state_gas_pricing/*. + "test_auth_state_gas_scales_with_cpsb[fork_Amsterdam", + // EIP-8037 โ€” for_amsterdam/amsterdam/eip8037_state_creation_gas_cost_increase/state_gas_sstore/*. + "test_sstore_state_gas_all_tx_types[fork_Amsterdam", + // EIP-7928 โ€” for_amsterdam/amsterdam/eip7928_block_level_access_lists/block_access_lists_eip7702/*. + // BAL coverage of EIP-7702 delegation flows; expected BAL diffs depend + // on the same set_delegation refund accounting as above. + "test_bal_7702_delegation_clear[fork_Amsterdam", + "test_bal_7702_delegation_create[fork_Amsterdam", + "test_bal_7702_delegation_update[fork_Amsterdam", + "test_bal_7702_double_auth_reset[fork_Amsterdam", + "test_bal_7702_double_auth_swap[fork_Amsterdam", + "test_bal_7702_null_address_delegation_no_code_change[fork_Amsterdam", + "test_bal_selfdestruct_to_7702_delegation[fork_Amsterdam", + "test_bal_withdrawal_to_7702_delegation[fork_Amsterdam", + // EIP-7928 โ€” for_amsterdam/amsterdam/eip7928_block_level_access_lists/block_access_lists/*. + // Aggregate BAL test exercising every tx type incl. set-code; trips + // for the same reason as the eip7702 BAL bucket. + "test_bal_all_transaction_types[fork_Amsterdam", + // EIP-7778 โ€” for_amsterdam/amsterdam/eip7778_block_gas_accounting_without_refunds/gas_accounting/*. + // Block-level gas accounting tests that interact with the auth refund + // path; tracked alongside the EIP-7702 bucket. + "test_multiple_refund_types_in_one_tx[fork_Amsterdam", + "test_simple_gas_accounting[fork_Amsterdam", + "test_varying_calldata_costs[fork_Amsterdam", + // EIP-7708 โ€” for_amsterdam/amsterdam/eip7708_eth_transfer_logs/transfer_logs/*. + // ETH-transfer-logs aggregate test; fails on the set-code tx variant. + "test_transfer_with_all_tx_types[fork_Amsterdam", + // EIP-7976 โ€” for_amsterdam/amsterdam/eip7976_increase_calldata_floor_cost/refunds/*. + // Calldata-floor refund accounting; interacts with the same auth-refund + // accounting changes. + "test_gas_refunds_from_data_floor[fork_Amsterdam", + // EIP-1344 โ€” for_amsterdam/istanbul/eip1344_chainid/chainid/*. + // Istanbul chainid test re-run as an Amsterdam fork-transition fixture; + // currently trips on the transition-test runner path rather than on the + // chainid opcode itself. + "test_chainid[fork_Amsterdam", ]; // Extra skips added only for prover backends. diff --git a/tooling/ef_tests/state/.fixtures_url_amsterdam b/tooling/ef_tests/state/.fixtures_url_amsterdam index 2290401371e..78577b44c5f 100644 --- a/tooling/ef_tests/state/.fixtures_url_amsterdam +++ b/tooling/ef_tests/state/.fixtures_url_amsterdam @@ -1 +1 @@ -https://github.com/ethereum/execution-spec-tests/releases/download/bal%40v5.6.1/fixtures_bal.tar.gz +https://github.com/ethereum/execution-spec-tests/releases/download/snobal-devnet-6%40v1.1.0/fixtures_snobal-devnet-6.tar.gz diff --git a/tooling/ef_tests/state/runner/levm_runner.rs b/tooling/ef_tests/state/runner/levm_runner.rs index 4990484dd9d..20c4d153c40 100644 --- a/tooling/ef_tests/state/runner/levm_runner.rs +++ b/tooling/ef_tests/state/runner/levm_runner.rs @@ -230,6 +230,7 @@ pub fn prepare_vm_for_tx<'a>( is_privileged: false, fee_token: None, disable_balance_check: false, + is_system_call: false, }, db, &tx, diff --git a/tooling/ef_tests/state_v2/src/modules/runner.rs b/tooling/ef_tests/state_v2/src/modules/runner.rs index 9d9e2ce1c14..3a94e656566 100644 --- a/tooling/ef_tests/state_v2/src/modules/runner.rs +++ b/tooling/ef_tests/state_v2/src/modules/runner.rs @@ -150,6 +150,7 @@ pub fn get_vm_env_for_test( is_privileged: false, fee_token: None, disable_balance_check: false, + is_system_call: false, }) }